diff --git a/.agents/skills/lint-creator/SKILL.md b/.agents/skills/lint-creator/SKILL.md new file mode 100644 index 00000000000000..e72e83a9abaf1e --- /dev/null +++ b/.agents/skills/lint-creator/SKILL.md @@ -0,0 +1,17 @@ +--- +name: lint-creator +description: An auxiliary skill to add more dylints to `tooling/lints` +disable-model-invocation: false +--- + +# Lint RULES + +1. Every lint MUST have accompanying `ui` tests +2. `ui` tests MUST be in the `ui` folder +3. Every lint MUST be in a separate module +4. Every lint MUST have negative `ui` tests +5. Lints should be as simple as possible. +6. Reporting is fine if it's simple, it does not need to be elaborate or lengthy code. +7. Do NOT suggest how to fix the lint, only flag it. +8. Do NOT make lints machine applicable. +9. Detect if lints are redundant vs clippy's capabilities. diff --git a/.cloudflare/docs-proxy/src/worker.js b/.cloudflare/docs-proxy/src/worker.js index 08b0265fafbbb0..99fca0c7116614 100644 --- a/.cloudflare/docs-proxy/src/worker.js +++ b/.cloudflare/docs-proxy/src/worker.js @@ -1,6 +1,11 @@ export default { async fetch(request, _env, _ctx) { const url = new URL(request.url); + const acceptHeader = request.headers.get("Accept") || ""; + const wantsMarkdown = acceptHeader + .split(",") + .map((mediaType) => mediaType.split(";")[0].trim().toLowerCase()) + .includes("text/markdown"); if (url.pathname === "/docs/nightly") { url.hostname = "docs-nightly.pages.dev"; @@ -18,6 +23,14 @@ export default { url.hostname = "docs-anw.pages.dev"; } + if (url.pathname === "/docs.md") { + url.pathname = "/docs/getting-started.md"; + } + + if (wantsMarkdown) { + url.pathname = markdownPathFor(url.pathname); + } + let res = await fetch(url, request); if (res.status === 404) { @@ -27,3 +40,31 @@ export default { return res; }, }; + +function markdownPathFor(pathname) { + if (pathname === "/docs" || pathname === "/docs/") { + return "/docs/getting-started.md"; + } + + if (pathname.endsWith("/index.md")) { + return pathname.replace(/\/index\.md$/, "/getting-started.md"); + } + + if (pathname.endsWith(".md")) { + return pathname; + } + + if (pathname.endsWith(".html")) { + return pathname.replace(/\.html$/, ".md"); + } + + if (pathname.split("/").pop().includes(".")) { + return pathname; + } + + if (pathname.endsWith("/")) { + return `${pathname}getting-started.md`; + } + + return `${pathname}.md`; +} diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index 0e6ab04228d43c..fea437d4ff9206 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -304,7 +304,6 @@ /crates/picker/ @zed-industries/ui-team /crates/refineable/ @zed-industries/ui-team /crates/story/ @zed-industries/ui-team -/crates/storybook/ @zed-industries/ui-team /crates/svg_preview/ @zed-industries/ui-team /crates/tab_switcher/ @zed-industries/ui-team /crates/theme/ @zed-industries/ui-team diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 9fb93ee27d5518..5a833447e9eda1 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -22,6 +22,8 @@ on: description: body type: string default: '' +permissions: + contents: read jobs: rebuild_releases_page: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 9918f6be0fc933..2e7c6e8a397a70 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -13,9 +13,14 @@ on: description: run_clippy type: boolean default: 'true' +permissions: + contents: read jobs: run_autofix: runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: read + pull-requests: read env: CC: clang CXX: clang++ @@ -43,7 +48,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 with: version: '9' - name: autofix_pr::run_autofix::install_cargo_machete diff --git a/.github/workflows/bump_collab_staging.yml b/.github/workflows/bump_collab_staging.yml index 4f9724439f37b2..be39f41b6e54e5 100644 --- a/.github/workflows/bump_collab_staging.yml +++ b/.github/workflows/bump_collab_staging.yml @@ -5,10 +5,15 @@ on: # Fire every day at 16:00 UTC (At the start of the US workday) - cron: "0 16 * * *" +permissions: + contents: read + jobs: update-collab-staging-tag: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 3618d7230f79b4..7be909c907ccb1 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -8,10 +8,14 @@ on: description: Branch name to run on required: true type: string +permissions: + contents: read jobs: run_bump_patch_version: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy diff --git a/.github/workflows/bump_zed_version.yml b/.github/workflows/bump_zed_version.yml index f8fae3de7afc0a..07b17aca32a162 100644 --- a/.github/workflows/bump_zed_version.yml +++ b/.github/workflows/bump_zed_version.yml @@ -8,6 +8,8 @@ on: description: 'Which channels to bump: all, main, preview, or stable' type: string default: all +permissions: + contents: read jobs: resolve_versions: if: github.repository_owner == 'zed-industries' @@ -86,6 +88,9 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'main' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write + pull-requests: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -127,6 +132,8 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'preview' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -180,6 +187,8 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'stable' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index 82dc9fb545d027..48474ac0bd55e5 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -21,14 +21,12 @@ on: description: pr_number required: true type: string +permissions: + contents: read jobs: run_cherry_pick: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - id: generate-token name: steps::authenticate_as_zippy uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 @@ -38,6 +36,11 @@ jobs: permission-contents: write permission-workflows: write permission-pull-requests: write + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + token: ${{ steps.generate-token.outputs.token }} - name: cherry_pick::run_cherry_pick::cherry_pick run: ./script/cherry-pick "$BRANCH" "$COMMIT" "$CHANNEL" env: diff --git a/.github/workflows/comment_on_potential_duplicate_issues.yml b/.github/workflows/comment_on_potential_duplicate_issues.yml index 0d7ce3aad3ce9d..c6e79bff8488ff 100644 --- a/.github/workflows/comment_on_potential_duplicate_issues.yml +++ b/.github/workflows/comment_on_potential_duplicate_issues.yml @@ -14,6 +14,8 @@ concurrency: group: potential-duplicate-check-${{ github.event.issue.number || inputs.issue_number }} cancel-in-progress: true +permissions: {} + jobs: identify-duplicates: # For manual testing, allow running on any branch; for automatic runs, only on main repo diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index be1d8e66d046ae..f309dd589b7d20 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -13,10 +13,15 @@ on: type: number default: 1000 +permissions: + contents: read + jobs: stale: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + issues: write steps: - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10 with: diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index b8003a69b243c3..55ba214ad30568 100644 --- a/.github/workflows/community_update_all_top_ranking_issues.yml +++ b/.github/workflows/community_update_all_top_ranking_issues.yml @@ -5,10 +5,16 @@ on: - cron: "0 */12 * * *" workflow_dispatch: +permissions: + contents: read + jobs: update_top_ranking_issues: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv diff --git a/.github/workflows/community_update_weekly_top_ranking_issues.yml b/.github/workflows/community_update_weekly_top_ranking_issues.yml index 90d1934ffcb6d5..4dbe5e8700a365 100644 --- a/.github/workflows/community_update_weekly_top_ranking_issues.yml +++ b/.github/workflows/community_update_weekly_top_ranking_issues.yml @@ -5,10 +5,16 @@ on: - cron: "0 15 * * *" workflow_dispatch: +permissions: + contents: read + jobs: update_top_ranking_issues: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv diff --git a/.github/workflows/compliance_check.yml b/.github/workflows/compliance_check.yml index 2cf27fea8b0652..a701371d76ea40 100644 --- a/.github/workflows/compliance_check.yml +++ b/.github/workflows/compliance_check.yml @@ -7,6 +7,8 @@ on: schedule: - cron: 30 17 * * 2 workflow_dispatch: {} +permissions: + contents: read jobs: scheduled_compliance_check: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 4866b3c33bc6ba..f6d04265975451 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -4,6 +4,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: check-author: if: ${{ github.repository_owner == 'zed-industries' }} diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 4e94c613a6b3dc..0ec9b51f1be5e6 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -11,6 +11,8 @@ on: - edited branches: - main +permissions: + contents: read jobs: danger: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -21,7 +23,7 @@ jobs: with: clean: false - name: steps::setup_pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 with: version: '9' - name: steps::setup_node diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 043c18ebc7c262..b40c55afb14ac7 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -7,6 +7,8 @@ on: push: tags: - collab-production +permissions: + contents: read jobs: style: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 6c492135ea6c3d..ace3c0729e7f2a 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -35,6 +35,8 @@ on: description: Git ref to checkout and deploy. Defaults to event SHA when omitted. type: string default: '' +permissions: + contents: read jobs: deploy_docs: if: github.repository_owner == 'zed-industries' diff --git a/.github/workflows/deploy_nightly_docs.yml b/.github/workflows/deploy_nightly_docs.yml index acd904841bb33c..12d10f10b26161 100644 --- a/.github/workflows/deploy_nightly_docs.yml +++ b/.github/workflows/deploy_nightly_docs.yml @@ -5,6 +5,7 @@ on: push: branches: - main +permissions: {} jobs: deploy_docs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/docs_suggestions.yml b/.github/workflows/docs_suggestions.yml index c3d04d5780b290..f0405af5e94424 100644 --- a/.github/workflows/docs_suggestions.yml +++ b/.github/workflows/docs_suggestions.yml @@ -42,6 +42,10 @@ on: - immediate default: batch +# Both jobs below declare their own `permissions:` blocks, so no job uses this +# top-level default. Least privilege therefore grants nothing here. +permissions: {} + env: DROID_MODEL: claude-sonnet-4-5-20250929 SUGGESTIONS_BRANCH: docs/suggestions-pending diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml index e48ccdb082a362..df9fcc986937a0 100644 --- a/.github/workflows/extension_auto_bump.yml +++ b/.github/workflows/extension_auto_bump.yml @@ -10,6 +10,8 @@ on: - '!extensions/test-extension/**' - '!extensions/workflows/**' - '!extensions/*.md' +permissions: + contents: read jobs: detect_changed_extensions: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 6e68db7af0f274..494a97b0977469 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -28,6 +28,8 @@ on: app-secret: description: The app secret for the corresponding app ID required: true +permissions: + contents: read jobs: check_version_changed: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 23efa368d17653..9a6f223f38f22e 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -15,6 +15,8 @@ on: description: working-directory type: string default: . +permissions: + contents: read jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index c1e61822df6b23..d1a47870e6f486 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -14,9 +14,11 @@ on: description: Description for the changes to be expected with this rollout type: string default: '' +permissions: + contents: read jobs: fetch_extension_repos: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && (github.ref == 'refs/tags/extension-workflows' || github.ref == 'refs/heads/main') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: checkout_zed_repo @@ -220,21 +222,18 @@ jobs: clean: false fetch-depth: 0 token: ${{ steps.generate-token.outputs.token }} - - name: extension_workflow_rollout::create_rollout_tag::update_rollout_tag - run: | - if git rev-parse "extension-workflows" >/dev/null 2>&1; then - git tag -d "extension-workflows" - git push origin ":refs/tags/extension-workflows" || true - fi - - echo "Creating new tag 'extension-workflows' at $(git rev-parse --short HEAD)" - git tag "extension-workflows" - git push origin "extension-workflows" - env: - GIT_AUTHOR_NAME: zed-zippy[bot] - GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: zed-zippy[bot] - GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + - name: steps::update_tag + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + with: + script: | + github.rest.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'tags/extension-workflows', + sha: context.sha, + force: true + }) + github-token: ${{ steps.generate-token.outputs.token }} timeout-minutes: 1 defaults: run: diff --git a/.github/workflows/good_first_issue_notifier.yml b/.github/workflows/good_first_issue_notifier.yml index fc1b49424dce24..9e86118dd0ab24 100644 --- a/.github/workflows/good_first_issue_notifier.yml +++ b/.github/workflows/good_first_issue_notifier.yml @@ -4,6 +4,9 @@ on: issues: types: [labeled] +permissions: + contents: read + jobs: handle-good-first-issue: if: github.event.label.name == '.contrib/good first issue' && github.repository_owner == 'zed-industries' diff --git a/.github/workflows/guild_assignment_status.yml b/.github/workflows/guild_assignment_status.yml new file mode 100644 index 00000000000000..77f03168c4e4c5 --- /dev/null +++ b/.github/workflows/guild_assignment_status.yml @@ -0,0 +1,59 @@ +# Guild board (https://github.com/orgs/zed-industries/projects/74) reactions to issue events: +# assigned guild member -> Status "In Progress" (or Slack if off-board) +# unassigned guild member -> move back to a To-Do column by Type + Slack +# commented guild assignee comments after a check-in -> Slack (each comment) + +name: Guild Assignment Status + +on: + issues: + types: [assigned, unassigned] + issue_comment: + types: [created] + +permissions: + contents: read + +concurrency: + group: guild-assignment-status-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: false + +jobs: + handle-event: + if: >- + github.repository == 'zed-industries/zed' && + (github.event_name != 'issue_comment' || github.event.issue.pull_request == null) + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Generate app token + id: app-token + 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 }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Handle issue event + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: event + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml new file mode 100644 index 00000000000000..afa9b07d87b7d1 --- /dev/null +++ b/.github/workflows/guild_new_pr_notify.yml @@ -0,0 +1,56 @@ +# When a guild member opens a PR, react on the Guild board (#74): +# - if the PR will close an open board issue, set that issue to In Progress +# (covers starting work without self-assigning), and +# - if they already have another open PR opened since the cohort started, post +# a gentle heads-up to #zed-guild-internal. + +name: Guild New PR Notification + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + +concurrency: + group: guild-new-pr-notify-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + react: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Generate app token + id: app-token + 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 }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: React to the new PR + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: event + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_stale_assignments.yml b/.github/workflows/guild_stale_assignments.yml new file mode 100644 index 00000000000000..90b47988c3fd61 --- /dev/null +++ b/.github/workflows/guild_stale_assignments.yml @@ -0,0 +1,57 @@ +# Scheduled sweep of the Guild board (https://github.com/orgs/zed-industries/projects/74). +# For an "In Progress" issue assigned to a guild member with no linked PR: post a +# check-in comment once the assignee goes quiet, re-nudging after any renewed +# silence, and clear the assignment (moving the issue back to a To-Do column by +# Type) if a check-in goes unanswered. The "guild hold" label pauses the sweep. + +name: Guild Stale Assignments + +on: + schedule: + - cron: "0 8 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-stale-assignments + cancel-in-progress: true + +jobs: + sweep: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + 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 }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Sweep stale assignments + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: stale + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_weekly_shipped.yml b/.github/workflows/guild_weekly_shipped.yml new file mode 100644 index 00000000000000..9f2a7fb4c8ccbf --- /dev/null +++ b/.github/workflows/guild_weekly_shipped.yml @@ -0,0 +1,55 @@ +# Scheduled Slack digest of Guild board +# (https://github.com/orgs/zed-industries/projects/74) issues recently closed by +# a merged PR authored by a guild member. + +name: Guild Weekly Shipped + +on: + schedule: + - cron: "0 7 * * 3" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-weekly-shipped + cancel-in-progress: true + +jobs: + digest: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + 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 }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Build and send digest + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: weekly + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/hotfix-review-monitor.yml b/.github/workflows/hotfix-review-monitor.yml index 760cd9806c9928..c6e4bfe6e53b35 100644 --- a/.github/workflows/hotfix-review-monitor.yml +++ b/.github/workflows/hotfix-review-monitor.yml @@ -21,12 +21,13 @@ on: permissions: contents: read - pull-requests: read jobs: check-hotfix-reviews: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest + permissions: + pull-requests: read timeout-minutes: 5 env: REPO: ${{ github.repository }} diff --git a/.github/workflows/nix_build.yml b/.github/workflows/nix_build.yml index f658634c06c166..3ddd6bdd5c6e16 100644 --- a/.github/workflows/nix_build.yml +++ b/.github/workflows/nix_build.yml @@ -9,6 +9,8 @@ on: types: - labeled - synchronize +permissions: + contents: read jobs: build_nix_linux_x86_64: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling')))) diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml index fbba166a2446d9..524d301aecc865 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -41,47 +41,9 @@ jobs: const STAFF_TEAM_SLUG = 'staff'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const GUILD_LABEL = 'guild'; - const GUILD_MEMBERS = [ - '11happy', - 'AidanV', - 'alanpjohn', - 'AmaanBilwar', - 'arjunkomath', - 'austincummings', - 'ayushk-1801', - 'criticic', - 'dongdong867', - 'emamulandalib', - 'eureka928', - 'feitreim', - 'iam-liam', - 'iksuddle', - 'ishaksebsib', - 'lingyaochu', - 'loadingalias', - 'marcocondrache', - 'mchisolm0', - 'MostlyKIGuess', - 'nairadithya', - 'nihalxkumar', - 'notJoon', - 'OmChillure', - 'Palanikannan1437', - 'polyesterswing', - 'prayanshchh', - 'razeghi71', - 'sarmadgulzar', - 'seanstrom', - 'Shivansh-25', - 'SkandaBhat', - 'th0jensen', - 'tommyming', - 'transitoryangel', - 'TwistingTwists', - 'virajbhartiya', - 'YEDASAVG', - 'Ziqi-Yang', - ]; + // Guild cohort members are outside collaborators holding this custom + // repository role, not members of an org team. + const GUILD_ROLE_NAME = 'Guild Assign issues/PRs'; const COMMUNITY_CHAMPION_LABEL = 'community champion'; const COMMUNITY_CHAMPIONS = [ '0x2CA', @@ -161,11 +123,11 @@ jobs: return members.some((member) => member.toLowerCase() === authorLower); }; - const isStaffMember = async (author) => { + const isTeamMember = async (teamSlug, author) => { try { const response = await github.rest.teams.getMembershipForUserInOrg({ org: 'zed-industries', - team_slug: STAFF_TEAM_SLUG, + team_slug: teamSlug, username: author }); return response.data.state === 'active'; @@ -177,6 +139,27 @@ jobs: } }; + const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author); + + const isGuildMember = async (author) => { + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: 'zed-industries', + repo: 'zed', + username: author + }); + // role_name is the effective (highest) role; for cohort outside + // collaborators that is the custom role. Built-in roles come back + // lowercased and won't match. + return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase(); + } catch (error) { + if (error.status !== 404) { + throw error; + } + return false; + } + }; + const getIssueLabels = () => { if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { return [COMMUNITY_CHAMPION_LABEL]; @@ -202,7 +185,7 @@ jobs: labelsToAdd.push(COMMUNITY_CHAMPION_LABEL); } - if (listIncludesAuthor(GUILD_MEMBERS, author)) { + if (await isGuildMember(author)) { labelsToAdd.push(GUILD_LABEL); } diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index b2d8e96fcea1b5..bee3c7c21f51e5 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -11,6 +11,8 @@ on: description: Describe why the extension CLI is being bumped and/or what changes are included. required: true type: string +permissions: + contents: read jobs: publish_job: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8115259c0b78b1..5a3808e06323c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: push: tags: - v* +permissions: + contents: read jobs: run_tests_mac: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -723,6 +725,8 @@ jobs: - bundle_windows_aarch64 - bundle_windows_x86_64 runs-on: namespace-profile-4x8-ubuntu-2204 + permissions: + contents: write steps: - name: release::download_workflow_artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -756,6 +760,8 @@ jobs: needs: - upload_release_assets runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + contents: write steps: - name: release::validate_release_assets run: | diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index e1c72767d77117..cf9f4728aa4595 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -8,6 +8,8 @@ on: schedule: - cron: 0 */4 * * * workflow_dispatch: {} +permissions: + contents: read jobs: check_nightly_tag: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index b05015677c2132..4299bac4013a0b 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -9,6 +9,8 @@ on: types: - labeled - synchronize +permissions: + contents: read jobs: bundle_linux_aarch64: if: |- diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 3b613f255cc165..27c0819d7c90f5 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -14,6 +14,8 @@ on: branches: - main - v[0-9]+.[0-9]+.x +permissions: + contents: read jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -73,12 +75,17 @@ jobs: # Map directory names to package names FILE_CHANGED_PKGS="" for dir in $CHANGED_DIRS; do - pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1) + pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1 || true) + # Only add directories that map to a real root-workspace package. + # Some directories (e.g. tooling/lints) belong to a separate workspace + # and are not root members, so they have no mapping here. Previously we + # fell back to the raw directory name, which fabricated a bogus package + # (e.g. "lints") and produced a nextest filter like rdeps(lints) that + # hard-errors ("operator didn't match any packages"). Skipping such + # directories leaves the package set empty, which falls through to the + # "run all tests" path below. if [ -n "$pkg" ]; then FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg") - else - # Fall back to directory name if no mapping found - FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$dir") fi done FILE_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' | sort -u || true) @@ -150,7 +157,7 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 with: version: '9' - name: steps::prettier diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index 688cfdded83a76..d02b3b23c232eb 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -12,8 +12,15 @@ on: - "Community PR Board" - "PR Board Meta Fields Refresh" - "PR Issue Labeler" + - "Guild Assignment Status" + - "Guild Stale Assignments" + - "Guild Weekly Shipped" + - "Guild New PR Notification" types: [completed] +permissions: + contents: read + jobs: notify-slack: if: >- diff --git a/.github/workflows/slack_notify_first_responders.yml b/.github/workflows/slack_notify_first_responders.yml index 3dd9ffeabae1b7..c8d970451ddf65 100644 --- a/.github/workflows/slack_notify_first_responders.yml +++ b/.github/workflows/slack_notify_first_responders.yml @@ -4,32 +4,48 @@ on: issues: types: [labeled] +permissions: + contents: read + env: PRIORITY_LABELS: '["priority:P0", "priority:P1"]' REPRODUCIBLE_LABEL: 'state:reproducible' FREQUENCY_LABELS: '["frequency:always", "frequency:common"]' + MARKER_REACTION: 'eyes' jobs: notify-slack: if: github.repository_owner == 'zed-industries' && github.event.issue.state == 'open' runs-on: namespace-profile-2x4-ubuntu-2404 - # Serialize per-issue so concurrent `labeled` events can't both observe - # the trifecta and double-notify. + permissions: + contents: read + # For the issue reaction used to dedupe notifications. + issues: write + # Serialize per issue so the reaction claim below can't race. concurrency: group: slack-notify-first-responders-${{ github.event.issue.number }} cancel-in-progress: false steps: - - name: Check if label combination requires first responder notification + - name: Check label combination and claim the notification id: check-label env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} LABEL_NAME: ${{ github.event.label.name }} - ISSUE_LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} + ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -euo pipefail - # Gate on the just-added label so unrelated labeling on an - # already-qualifying issue doesn't re-fire the notification. + api() { + curl -sS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + # Ignore labels outside the trifecta so unrelated later edits don't re-fire. TRIGGER_LABELS=$(jq -cn \ --argjson priority "$PRIORITY_LABELS" \ --arg repro "$REPRODUCIBLE_LABEL" \ @@ -42,19 +58,52 @@ jobs: exit 0 fi + # The webhook's issue.labels snapshot is racy under bulk apply; read live. + ISSUE_LABELS_JSON=$(api -f "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/labels?per_page=100" | jq '[.[].name]') + MATCHED_PRIORITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson priority "$PRIORITY_LABELS" 'map(select(. as $x | $priority | index($x) != null)) | first // ""') HAS_REPRO=$(echo "$ISSUE_LABELS_JSON" | jq --arg l "$REPRODUCIBLE_LABEL" 'index($l) != null') HAS_FREQ=$(echo "$ISSUE_LABELS_JSON" | jq --argjson freq "$FREQUENCY_LABELS" 'any(.[]; . as $x | $freq | index($x) != null)') - if [ -n "$MATCHED_PRIORITY" ] && [ "$HAS_REPRO" = "true" ] && [ "$HAS_FREQ" = "true" ]; then - echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying" - echo "notify_reason=confirmed $MATCHED_PRIORITY" >> "$GITHUB_OUTPUT" - echo "should_notify=true" >> "$GITHUB_OUTPUT" - else + if [ -z "$MATCHED_PRIORITY" ] || [ "$HAS_REPRO" != "true" ] || [ "$HAS_FREQ" != "true" ]; then echo "Combination not yet satisfied (priority=$MATCHED_PRIORITY, reproducible=$HAS_REPRO, frequency=$HAS_FREQ), skipping" echo "should_notify=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A bulk label apply emits one `labeled` event per label, so several + # runs reach this point. Creating the reaction is our atomic claim: + # one run gets 201 and notifies, the rest get 200. It's scoped to our + # own reaction, so a human's reaction can't suppress it. + REACTION_PAYLOAD=$(jq -cn --arg content "$MARKER_REACTION" '{content: $content}') + REACTION_RESPONSE=$(api -w "\n%{http_code}" -X POST \ + "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions" \ + -d "$REACTION_PAYLOAD") + REACTION_BODY=$(echo "$REACTION_RESPONSE" | sed '$d') + REACTION_STATUS=$(echo "$REACTION_RESPONSE" | tail -n1) + + if [ "$REACTION_STATUS" = "200" ]; then + echo "First responders already notified for this issue (reaction present), skipping" + echo "should_notify=false" >> "$GITHUB_OUTPUT" + exit 0 fi + if [ "$REACTION_STATUS" != "201" ]; then + echo "::error::Unexpected status $REACTION_STATUS creating reaction: $REACTION_BODY" + exit 1 + fi + + REACTION_ID=$(echo "$REACTION_BODY" | jq -r '.id') + LABELS=$(echo "$ISSUE_LABELS_JSON" | jq -r 'join(", ")') + + echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying" + { + echo "notify_reason=confirmed $MATCHED_PRIORITY" + echo "issue_labels=$LABELS" + echo "reaction_id=$REACTION_ID" + echo "should_notify=true" + } >> "$GITHUB_OUTPUT" + - name: Build Slack message payload if: steps.check-label.outputs.should_notify == 'true' env: @@ -62,10 +111,8 @@ jobs: ISSUE_URL: ${{ github.event.issue.html_url }} LABELED_BY: ${{ github.event.sender.login }} NOTIFY_REASON: ${{ steps.check-label.outputs.notify_reason }} - LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} + LABELS: ${{ steps.check-label.outputs.issue_labels }} run: | - LABELS=$(echo "$LABELS_JSON" | jq -r 'join(", ")') - jq -n \ --arg notify_reason "$NOTIFY_REASON" \ --arg issue_title "$ISSUE_TITLE" \ @@ -108,9 +155,27 @@ jobs: if: steps.check-label.outputs.should_notify == 'true' env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_FIRST_RESPONDERS }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REACTION_ID: ${{ steps.check-label.outputs.reaction_id }} run: | + set -uo pipefail + + release_claim() { + if [ -n "${REACTION_ID:-}" ]; then + echo "Releasing reaction claim so the notification can be retried" + curl -sS -X DELETE \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions/$REACTION_ID" || true + fi + } + if [ -z "$SLACK_WEBHOOK_URL" ]; then echo "::error::SLACK_WEBHOOK_FIRST_RESPONDERS secret is not set" + release_claim exit 1 fi @@ -126,6 +191,7 @@ jobs: if [ "$HTTP_STATUS" -ne 200 ]; then echo "::error::Slack notification failed with status $HTTP_STATUS: $HTTP_BODY" + release_claim exit 1 fi diff --git a/.github/workflows/slack_notify_label_created.yml b/.github/workflows/slack_notify_label_created.yml index e791cbc7ea4c37..f30650dc2204a8 100644 --- a/.github/workflows/slack_notify_label_created.yml +++ b/.github/workflows/slack_notify_label_created.yml @@ -4,6 +4,9 @@ on: label: types: [created] +permissions: + contents: read + jobs: notify-slack: if: >- diff --git a/.github/workflows/stale-pr-reminder.yml b/.github/workflows/stale-pr-reminder.yml index 1c3c0aec623c68..a35430857ecf8e 100644 --- a/.github/workflows/stale-pr-reminder.yml +++ b/.github/workflows/stale-pr-reminder.yml @@ -20,13 +20,14 @@ on: permissions: contents: read - pull-requests: read jobs: check-stale-prs: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + pull-requests: read env: REPO: ${{ github.repository }} # Only surface PRs created on or after this date. Update this if the diff --git a/.github/workflows/superzed_release.yml b/.github/workflows/superzed_release.yml new file mode 100644 index 00000000000000..a15de09c6dea17 --- /dev/null +++ b/.github/workflows/superzed_release.yml @@ -0,0 +1,231 @@ +# Fork-specific CD pipeline (not generated by `cargo xtask workflows`). +# +# On every push to `superzed` or `main`: if the version in crates/zed/Cargo.toml +# changed in that push and no GitHub release exists for it yet, build unsigned +# bundles for macOS (aarch64), Linux (x86_64), and Windows (x86_64) on +# GitHub-hosted runners and publish them as release v. +# +# `workflow_dispatch` skips the version-changed check (still skips if the +# release already exists) — use it to retry a failed release or backfill one. +name: superzed-release + +on: + push: + branches: + - superzed + - main + workflow_dispatch: {} + +permissions: + contents: write + +concurrency: + group: superzed-release-${{ github.ref_name }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + # The workspace release profile (thin LTO + codegen-units=1) needs more RAM + # than free GitHub-hosted runners have (7 GB on macOS arm64), and we never + # upload debug symbols, so relax the profile via environment overrides. + CARGO_PROFILE_RELEASE_LTO: 'false' + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '0' + # Compile-time kill switch for the auto-updater so fork builds never try to + # replace themselves with upstream Zed releases from zed.dev. + ZED_UPDATE_EXPLANATION: 'Superzed updates ship via GitHub Releases: https://github.com/o1x3/superzed/releases' + +jobs: + detect: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.check.outputs.version }} + tag: ${{ steps.check.outputs.tag }} + should_release: ${{ steps.check.outputs.should_release }} + steps: + - uses: actions/checkout@v7 + - id: check + name: Decide whether to release + env: + GH_TOKEN: ${{ github.token }} + BEFORE_SHA: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + run: | + version=$(grep -m1 '^version = ' crates/zed/Cargo.toml | cut -d'"' -f2) + if [ -z "$version" ]; then + echo "Could not read version from crates/zed/Cargo.toml" >&2 + exit 1 + fi + tag="v${version}" + + release_exists=false + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + release_exists=true + fi + + version_changed=false + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + version_changed=true + elif [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then + # New branch or unknown base: fall back to the release-exists check only. + version_changed=true + else + old_version=$(gh api "repos/$GITHUB_REPOSITORY/contents/crates/zed/Cargo.toml?ref=$BEFORE_SHA" \ + --jq '.content' 2>/dev/null | base64 -d | grep -m1 '^version = ' | cut -d'"' -f2 || true) + if [ -z "$old_version" ] || [ "$old_version" != "$version" ]; then + version_changed=true + fi + fi + + should_release=false + if [ "$release_exists" = false ] && [ "$version_changed" = true ]; then + should_release=true + fi + + { + echo "version=$version" + echo "tag=$tag" + echo "should_release=$should_release" + } >> "$GITHUB_OUTPUT" + echo "version=$version version_changed=$version_changed release_exists=$release_exists => should_release=$should_release" + + bundle-mac: + needs: detect + if: needs.detect.outputs.should_release == 'true' + runs-on: macos-15 + timeout-minutes: 360 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - name: Set release channel to stable + run: echo stable > crates/zed/RELEASE_CHANNEL + - name: Install Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - name: Bundle macOS app (aarch64) + run: ./script/bundle-mac aarch64-apple-darwin + - uses: actions/upload-artifact@v7 + with: + name: Superzed-macos-aarch64.dmg + path: target/aarch64-apple-darwin/release/Zed-aarch64.dmg + if-no-files-found: error + - uses: actions/upload-artifact@v7 + with: + name: superzed-remote-server-macos-aarch64.gz + path: target/zed-remote-server-macos-aarch64.gz + if-no-files-found: error + + bundle-linux: + needs: detect + if: needs.detect.outputs.should_release == 'true' + # 22.04 (glibc 2.35) instead of 24.04: glibc 2.38+ headers emit + # __isoc23_* symbols into C objects, which then fail to link into the + # static musl remote_server. An older build host also lowers the glibc + # floor for users of the tarball. + runs-on: ubuntu-22.04 + timeout-minutes: 360 + env: + CC: clang-18 + CXX: clang++-18 + steps: + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL + df -h / + - uses: actions/checkout@v7 + - name: Set release channel to stable + run: echo stable > crates/zed/RELEASE_CHANNEL + - name: Install Linux dependencies + run: ./script/linux + - name: Install clang 18 + # webrtc-sys needs clang 17+; Ubuntu 22.04 ships clang 14. + run: | + wget -q https://apt.llvm.org/llvm.sh + chmod +x llvm.sh + sudo ./llvm.sh 18 + - name: Install Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - name: Bundle Linux tarball (x86_64) + run: ./script/bundle-linux + - uses: actions/upload-artifact@v7 + with: + name: superzed-linux-x86_64.tar.gz + path: target/release/zed-linux-x86_64.tar.gz + if-no-files-found: error + - uses: actions/upload-artifact@v7 + with: + name: superzed-remote-server-linux-x86_64.gz + path: target/zed-remote-server-linux-x86_64.gz + if-no-files-found: error + + bundle-windows: + needs: detect + if: needs.detect.outputs.should_release == 'true' + runs-on: windows-2022 + timeout-minutes: 360 + steps: + - name: Enable git long paths + run: git config --system core.longpaths true + - uses: actions/checkout@v7 + - name: Set release channel to stable + run: Set-Content -Path crates/zed/RELEASE_CHANNEL -Value 'stable' + - name: Install Rust toolchain + run: | + rustup show active-toolchain + if ($LASTEXITCODE -ne 0) { rustup toolchain install } + exit 0 + - name: Bundle Windows installer (x86_64) + run: script/bundle-windows.ps1 -Architecture x86_64 + - uses: actions/upload-artifact@v7 + with: + name: SuperzedSetup-windows-x86_64.exe + path: target/Zed-x86_64.exe + if-no-files-found: error + - uses: actions/upload-artifact@v7 + with: + name: superzed-remote-server-windows-x86_64.zip + path: target/zed-remote-server-windows-x86_64.zip + if-no-files-found: error + + release: + needs: + - detect + - bundle-mac + - bundle-linux + - bundle-windows + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/download-artifact@v8 + with: + path: artifacts + - name: Prepare release assets + run: | + mkdir -p release-assets + mv artifacts/Superzed-macos-aarch64.dmg/Zed-aarch64.dmg release-assets/Superzed-macos-aarch64.dmg + mv artifacts/superzed-linux-x86_64.tar.gz/zed-linux-x86_64.tar.gz release-assets/superzed-linux-x86_64.tar.gz + mv artifacts/SuperzedSetup-windows-x86_64.exe/Zed-x86_64.exe release-assets/SuperzedSetup-windows-x86_64.exe + mv artifacts/superzed-remote-server-macos-aarch64.gz/zed-remote-server-macos-aarch64.gz release-assets/superzed-remote-server-macos-aarch64.gz + mv artifacts/superzed-remote-server-linux-x86_64.gz/zed-remote-server-linux-x86_64.gz release-assets/superzed-remote-server-linux-x86_64.gz + mv artifacts/superzed-remote-server-windows-x86_64.zip/zed-remote-server-windows-x86_64.zip release-assets/superzed-remote-server-windows-x86_64.zip + ls -l release-assets/ + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.detect.outputs.tag }} + VERSION: ${{ needs.detect.outputs.version }} + run: | + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber release-assets/* + else + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "Superzed $VERSION" \ + --generate-notes \ + release-assets/* + fi + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' diff --git a/.github/workflows/update_duplicate_magnets.yml b/.github/workflows/update_duplicate_magnets.yml index d14f4aa92451aa..1d073ba0484479 100644 --- a/.github/workflows/update_duplicate_magnets.yml +++ b/.github/workflows/update_duplicate_magnets.yml @@ -5,10 +5,16 @@ on: - cron: "0 6 * * 1,4" # Mondays and Thursdays at 6 AM UTC workflow_dispatch: +permissions: + contents: read + jobs: update-duplicate-magnets: runs-on: ubuntu-latest if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.gitignore b/.gitignore index cf75babbcdd86f..43a48ce78cbdf2 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ crates/docs_preprocessor/actions.json # NixOS integration test state .nixos-test-history + +.local* diff --git a/Cargo.lock b/Cargo.lock index da4ac1b3b724e6..7ba49e571f819a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ + "enumn", "uuid", ] @@ -288,6 +289,7 @@ dependencies = [ "quick-xml 0.38.3", "rand 0.9.4", "regex", + "release_channel", "reqwest_client", "rust-embed", "sandbox", @@ -321,9 +323,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" +checksum = "ed34c8297a72c9d99fc7493ba6370f45c69fdcd51fdfb469d80f266cf63e5a98" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -343,9 +345,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b37d552feb6981a0109febda6b71fc723678cd065974c2d76279c19c407095" +checksum = "0de0877cd41073fa0a124edb972df0d02e88c70adde11f49cf2231fd63aae738" dependencies = [ "quote", "syn 2.0.117", @@ -353,9 +355,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" +checksum = "06679e1542356341f4550ccfb16338b64f37f6af70de2105446ef6fbb078234c" dependencies = [ "anyhow", "derive_more", @@ -367,6 +369,13 @@ dependencies = [ "tracing", ] +[[package]] +name = "agent_detect" +version = "0.1.0" +dependencies = [ + "regex", +] + [[package]] name = "agent_servers" version = "0.1.0" @@ -459,6 +468,7 @@ dependencies = [ "action_log", "agent", "agent-client-protocol", + "agent_detect", "agent_servers", "agent_settings", "agent_skills", @@ -493,6 +503,7 @@ dependencies = [ "heapless", "html_to_markdown", "http_client", + "idna", "image", "indoc", "itertools 0.14.0", @@ -534,6 +545,7 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", + "shlex", "streaming_diff", "task", "telemetry", @@ -546,7 +558,7 @@ dependencies = [ "time", "tree-sitter-md", "ui", - "ui_input", + "unicode-script", "unicode-segmentation", "unindent", "url", @@ -950,6 +962,7 @@ dependencies = [ "smol", "tempfile", "util", + "which 6.0.3", "windows 0.61.3", "zeroize", ] @@ -1231,15 +1244,15 @@ dependencies = [ [[package]] name = "async-tar" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1937db2d56578aa3919b9bdb0e5100693fd7d1c0f145c53eb81fbb03e217550" +checksum = "f6affe71e5b6180fb5eaf9e8127a243694baf6ae1120c199227167302f56c14b" dependencies = [ "async-std", "filetime", + "futures-core", "libc", - "pin-project", - "redox_syscall 0.2.16", + "redox_syscall 0.7.5", "xattr", ] @@ -1554,9 +1567,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -1565,14 +1578,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -2889,7 +2903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" dependencies = [ "heck 0.4.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "proc-macro2", "quote", @@ -3536,7 +3550,7 @@ name = "collections" version = "0.1.0" dependencies = [ "gpui_util", - "indexmap 2.11.4", + "indexmap 2.14.0", "rustc-hash 2.1.1", ] @@ -3906,6 +3920,7 @@ dependencies = [ "lsp", "menu", "project", + "release_channel", "serde_json", "settings", "ui", @@ -4174,36 +4189,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f81cede359311706057b689b91b59f464926de0316f389898a2b028cb494fa" +checksum = "3cd990d8a6304475bbad64534a0d418f5572f44d5f011437e6b9f1ee7d5c2570" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa6ca11305de425ea08884097b913ebe1a83875253b3c0063ce28411e226bfdc" +checksum = "ccabe4636007296721080e02d7dab46d4319638ec4e3f6f7402fcb46dc5122c6" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7537341a9a4ba9812141927be733e7254bf2318aab6597d567af9cad90609f27" +checksum = "da7ed173c870c0aea202a9830880156905a028a88df076e35ce383a8acbf90a7" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d28a4ca5faf25ff821fcc768f26e68ffef505e9f71bb06e608862d941fa65086" +checksum = "800cc586df98b12c502e76707c96565e40629a5322eaa15aaa34ba05f5721e31" dependencies = [ "serde", "serde_derive", @@ -4211,9 +4226,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d891057fe1b73910c41e73b32a70fa8454092fce65942b5fa6f72aa6d5487f8a" +checksum = "ae93f863f9094ae34d2567f9edb0ae2c41d35228b286598354dd78b198868ebd" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -4241,9 +4256,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c29a66028a78eedc534b3a94e5ebfbaeb4e1f6b09038afe41bb24afd614faa4b" +checksum = "38c505162bcf77dcb859905b3eac56a1917fc3cf326424fb06e7732031e3a8ae" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -4254,24 +4269,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95809ad251fe9422087b4a72d61e584d6ab6eff44dee1335f93cfaea0bedc9ac" +checksum = "e3b786958bcb79bdb5fbae095af58f0c2da7d7895c475c991f6a6bb5a9c7e6d9" [[package]] name = "cranelift-control" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79d0cacf063c297e5e8d5b73cb355b41b87f6d248e252d1b284e7a7b73673c2" +checksum = "2faf9a5009bce7f725ce2af7a08c4883ebac6af933e7e0aa7d84f976f4e6deb5" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d73297a195ce3be55997c6307142c4b1e58dd0c2f18ceaa0179444024e312a" +checksum = "017271194ba5e101d626560d0d6767efd341468d1ba0f4d015f19fe64020b65b" dependencies = [ "cranelift-bitset", "serde", @@ -4280,9 +4295,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be38d1ae29ef7c5d611fc6cb694f698dc4ca44152dcaa112ec0fef8d4d34858" +checksum = "f80847f0929967f0cec82f9e0543b3901e0f0063690405891f22107b5a130fd8" dependencies = [ "cranelift-codegen", "log", @@ -4292,15 +4307,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6761926f6636209de7ac568be28b206890f2181761375b9722e0a1e7a7e1637a" +checksum = "75904abbc0e7b46d20f7a49c8042c8a4481c0db4253b99889c723c566295d506" [[package]] name = "cranelift-native" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0893472f73f0d530a28e9a573ada6d1f93b9659bb6734dfe17061ac967bd1830" +checksum = "6e0135923540574362e16f01bf40000664263840991039ff3041ba717de6cf3a" dependencies = [ "cranelift-codegen", "libc", @@ -4309,9 +4324,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1daccebabb1ccd034dbab0eacc0722af27d3cccc7929dea27a3546cb3562e40" +checksum = "93fb12f76c482e034f6ebefa843c914e74112f088215d8d36d33a649f9fab99b" [[package]] name = "crash-context" @@ -4343,6 +4358,7 @@ version = "0.1.0" dependencies = [ "async-process", "crash-handler", + "libc", "log", "mach2 0.5.0", "minidumper", @@ -4629,7 +4645,7 @@ checksum = "d74b6bcf49ebbd91f1b1875b706ea46545032a14003b5557b7dfa4bbeba6766e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "scratch", @@ -4644,7 +4660,7 @@ checksum = "94ca2ad69673c4b35585edfa379617ac364bccd0ba0adf319811ba3a74ffa48a" dependencies = [ "clap", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4662,7 +4678,7 @@ version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8ebf0b6138325af3ec73324cb3a48b64d57721f17291b151206782e61f66cd" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5018,9 +5034,7 @@ dependencies = [ "text", "theme", "theme_settings", - "tree-sitter", "tree-sitter-go", - "tree-sitter-json", "ui", "ui_input", "unindent", @@ -5342,9 +5356,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -5808,6 +5822,7 @@ dependencies = [ "unicode-width", "unindent", "url", + "urlencoding", "util", "uuid", "vim_mode_setting", @@ -5987,6 +6002,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -6302,6 +6328,7 @@ dependencies = [ "log", "lsp", "parking_lot", + "path", "pretty_assertions", "proto", "semver", @@ -6313,8 +6340,8 @@ dependencies = [ "tracing", "url", "util", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", "ztracing", ] @@ -6394,7 +6421,7 @@ dependencies = [ "tracing", "url", "util", - "wasmparser 0.221.3", + "wasmparser 0.252.0", "wasmtime", "wasmtime-wasi", "zlog", @@ -6462,9 +6489,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ "bit-set 0.8.0", "regex-automata", @@ -6491,6 +6518,9 @@ name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "fax" @@ -6638,14 +6668,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", - "windows-sys 0.60.2", ] [[package]] @@ -6713,7 +6741,18 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "nanorand", + "spin 0.9.8", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand 2.3.0", + "futures-core", + "futures-sink", "spin 0.9.8", ] @@ -6864,6 +6903,7 @@ dependencies = [ "anyhow", "ashpd", "async-channel 2.5.0", + "async-std", "async-tar", "async-trait", "collections", @@ -6878,11 +6918,13 @@ dependencies = [ "log", "notify 9.0.0-rc.4", "parking_lot", + "path", "paths", "proto", "rope", "serde", "serde_json", + "slotmap", "smol", "telemetry", "tempfile", @@ -7246,7 +7288,7 @@ dependencies = [ "derive_more", "derive_setters", "gh-workflow-macros", - "indexmap 2.11.4", + "indexmap 2.14.0", "merge", "serde", "serde_json", @@ -7281,7 +7323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" dependencies = [ "fallible-iterator", - "indexmap 2.11.4", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -7367,6 +7409,7 @@ dependencies = [ "async-channel 2.5.0", "buffer_diff", "call", + "client", "collections", "component", "ctor", @@ -7853,6 +7896,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", + "notify-rust", "oo7", "open", "parking_lot", @@ -7888,6 +7932,7 @@ dependencies = [ "anyhow", "async-task", "block", + "block2 0.6.2", "cbindgen", "cocoa 0.26.0", "collections", @@ -7915,6 +7960,7 @@ dependencies = [ "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-user-notifications", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -8082,7 +8128,6 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-typescript", "tree-sitter-yaml", - "util", ] [[package]] @@ -8114,7 +8159,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8133,7 +8178,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8245,6 +8290,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -8921,7 +8977,6 @@ dependencies = [ "qoi", "ravif", "rayon", - "rgb", "tiff", "zune-core", "zune-jpeg", @@ -8965,11 +9020,12 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imara-diff" -version = "0.1.8" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" dependencies = [ "hashbrown 0.15.5", + "memchr", ] [[package]] @@ -8997,12 +9053,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -9710,16 +9766,16 @@ dependencies = [ "anyhow", "collections", "gpui_shared_string", + "gpui_util", "log", - "lsp", "parking_lot", + "path", "regex", "schemars 1.0.4", "serde", "serde_json", "toml 0.8.23", "tree-sitter", - "util", ] [[package]] @@ -9755,6 +9811,7 @@ dependencies = [ "env_var", "futures 0.3.32", "gpui", + "gpui_util", "http_client", "icons", "image", @@ -9764,7 +9821,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.17", - "util", ] [[package]] @@ -9779,6 +9835,7 @@ dependencies = [ "http_client", "log", "partial-json-fixer", + "pretty_assertions", "schemars 1.0.4", "serde", "serde_json", @@ -9796,6 +9853,7 @@ dependencies = [ "async-lock", "aws-config", "aws-credential-types", + "aws-sigv4", "aws_http_client", "base64 0.22.1", "bedrock", @@ -10506,10 +10564,12 @@ name = "lsp" version = "0.1.0" dependencies = [ "anyhow", + "async-channel 2.5.0", "async-pipe", "collections", "ctor", "futures 0.3.32", + "futures-lite 1.13.0", "gpui", "gpui_util", "log", @@ -10521,7 +10581,6 @@ dependencies = [ "semver", "serde", "serde_json", - "smol", "util", "zlog", ] @@ -10537,6 +10596,32 @@ dependencies = [ "url", ] +[[package]] +name = "lsp_locations" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "editor", + "file_icons", + "fuzzy", + "gpui", + "indoc", + "language", + "log", + "lsp", + "picker", + "picker_preview", + "project", + "settings", + "text", + "theme", + "theme_settings", + "ui", + "util", + "workspace", +] + [[package]] name = "lyon" version = "1.0.16" @@ -10606,6 +10691,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + [[package]] name = "mach2" version = "0.4.3" @@ -10638,7 +10737,7 @@ name = "manatee" version = "0.6.2" source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#9acc3960f04a7deeb08079d60fa8183f15e8bde1" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "nalgebra", "rustc-hash 2.1.1", "thiserror 2.0.17", @@ -10664,6 +10763,7 @@ dependencies = [ "gpui", "gpui_platform", "html5ever 0.27.0", + "image", "language", "languages", "linkify", @@ -10950,7 +11050,7 @@ dependencies = [ "chrono", "euclid", "htmlize", - "indexmap 2.11.4", + "indexmap 2.14.0", "json5", "lalrpop", "lalrpop-util", @@ -10976,7 +11076,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dugong", - "indexmap 2.11.4", + "indexmap 2.14.0", "manatee", "merman-core", "pulldown-cmark 0.12.2", @@ -11268,8 +11368,9 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "naga" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -11280,7 +11381,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap 2.11.4", + "indexmap 2.14.0", "libm", "log", "num-traits", @@ -11333,15 +11434,6 @@ dependencies = [ "rand 0.8.6", ] -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.16", -] - [[package]] name = "native-tls" version = "0.2.18" @@ -11442,6 +11534,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", ] [[package]] @@ -11581,6 +11674,20 @@ dependencies = [ "notify 6.1.1", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite 2.6.1", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "notify-types" version = "2.1.0" @@ -11993,6 +12100,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-services" version = "0.3.2" @@ -12096,6 +12213,19 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-core-location", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -12122,7 +12252,7 @@ checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "crc32fast", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "memchr", ] @@ -12364,9 +12494,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -12401,9 +12531,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -12493,6 +12623,7 @@ dependencies = [ "lsp", "menu", "picker", + "picker_preview", "project", "rope", "serde_json", @@ -12667,6 +12798,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "path" +version = "0.1.0" +dependencies = [ + "anyhow", + "dunce", + "serde", + "tempfile", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -13288,7 +13429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.11.4", + "indexmap 2.14.0", ] [[package]] @@ -13475,7 +13616,6 @@ dependencies = [ name = "picker_preview" version = "0.1.0" dependencies = [ - "anyhow", "editor", "gpui", "language", @@ -13603,7 +13743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "quick-xml 0.38.3", "serde", "time", @@ -13984,7 +14124,7 @@ dependencies = [ "dap", "encoding_rs", "extension", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "fs", "futures 0.3.32", "fuzzy", @@ -13995,7 +14135,7 @@ dependencies = [ "gpui", "http_client", "image", - "indexmap 2.11.4", + "indexmap 2.14.0", "itertools 0.14.0", "language", "log", @@ -14003,6 +14143,7 @@ dependencies = [ "markdown", "node_runtime", "parking_lot", + "path", "paths", "percent-encoding", "postage", @@ -14075,7 +14216,6 @@ dependencies = [ "anyhow", "client", "collections", - "command_palette_hooks", "criterion", "editor", "feature_flags", @@ -14087,6 +14227,7 @@ dependencies = [ "gpui", "itertools 0.14.0", "language", + "log", "markdown_preview", "menu", "notifications", @@ -14124,6 +14265,7 @@ dependencies = [ "lsp", "ordered-float 2.10.1", "picker", + "picker_preview", "project", "release_channel", "semver", @@ -14445,9 +14587,9 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "pulley-interpreter" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b78fdec962b639b921badfcfe77db7d18aa3c0c1e292ac2aa268c0efe8fe683" +checksum = "558181096e0df4984f45cfc3a7087052df4a61c36089b135a08ceca9cbd352fb" dependencies = [ "cranelift-bitset", "log", @@ -14457,9 +14599,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f718f4e8cd5fdfa08b3b1d2d25fe288350051be330544305f0a9b93a937b3d42" +checksum = "b5d52e2f14e168d75cdabe9bd5fb1ff18a1b119dc6699684aee895dbc3524da9" dependencies = [ "proc-macro2", "quote", @@ -14926,7 +15068,6 @@ dependencies = [ "task", "telemetry", "ui", - "ui_input", "util", "windows-registry 0.6.1", "workspace", @@ -14936,18 +15077,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", ] [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.10.0", ] @@ -15045,9 +15186,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -15973,6 +16114,8 @@ dependencies = [ "http_proxy", "libc", "log", + "nix 0.29.0", + "seccompiler", "serde", "serde_json", "smol", @@ -16005,7 +16148,7 @@ dependencies = [ "async-task", "backtrace", "chrono", - "flume", + "flume 0.12.0", "futures 0.3.32", "parking_lot", "rand 0.9.4", @@ -16046,7 +16189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", - "indexmap 2.11.4", + "indexmap 2.14.0", "ref-cast", "schemars_derive", "serde", @@ -16244,6 +16387,7 @@ dependencies = [ "anyhow", "bitflags 2.10.0", "collections", + "db", "editor", "file_icons", "fs", @@ -16289,6 +16433,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -16435,7 +16588,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -16449,7 +16602,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -16519,7 +16672,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.0.4", "serde_core", @@ -16546,7 +16699,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -16559,7 +16712,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -16701,6 +16854,7 @@ dependencies = [ "agent_skills", "anyhow", "audio", + "client", "cloud_api_types", "codestral", "collections", @@ -16856,10 +17010,12 @@ dependencies = [ "action_log", "agent", "agent-client-protocol", + "agent_detect", "agent_settings", "agent_ui", "anyhow", "async-channel 2.5.0", + "call", "chrono", "client", "clock", @@ -17321,7 +17477,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -17478,7 +17634,7 @@ checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.11.1", "futures-channel", "futures-core", "futures-executor", @@ -18185,9 +18341,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.10.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" +checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" dependencies = [ "arrayvec", "grid", @@ -18272,6 +18428,8 @@ dependencies = [ "serde", "serde_json", "task", + "tree-sitter", + "tree-sitter-json", "tree-sitter-rust", "tree-sitter-typescript", "ui", @@ -18280,6 +18438,17 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.17", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "telemetry" version = "0.1.0" @@ -18417,6 +18586,7 @@ dependencies = [ "serde_json", "settings", "shellexpand", + "shlex", "task", "terminal", "theme", @@ -18488,7 +18658,7 @@ dependencies = [ "clap", "collections", "gpui", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "palette", "serde", @@ -18748,11 +18918,13 @@ dependencies = [ "cloud_api_types", "command_palette_hooks", "db", + "editor", "fs", "git_ui", "gpui", "icons", "livekit_client", + "menu", "notifications", "platform_title_bar", "project", @@ -18949,7 +19121,7 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.0.3", "toml_datetime 0.7.3", @@ -18982,7 +19154,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -18996,7 +19168,7 @@ version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "toml_datetime 0.7.3", "toml_parser", "winnow 0.7.13", @@ -19242,7 +19414,7 @@ dependencies = [ [[package]] name = "trash" version = "5.2.5" -source = "git+https://github.com/zed-industries/trash-rs?rev=3bf27effd4eb8699f2e484d3326b852fe3e53af7#3bf27effd4eb8699f2e484d3326b852fe3e53af7" +source = "git+https://github.com/zed-industries/trash-rs?rev=47761739192828a66b11a94ba5420b82d63c03c5#47761739192828a66b11a94ba5420b82d63c03c5" dependencies = [ "chrono", "libc", @@ -19426,7 +19598,7 @@ checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" [[package]] name = "tree-sitter-md" version = "0.3.2" -source = "git+https://github.com/tree-sitter-grammars/tree-sitter-markdown?rev=9a23c1a96c0513d8fc6520972beedd419a973539#9a23c1a96c0513d8fc6520972beedd419a973539" +source = "git+https://github.com/zed-industries/tree-sitter-markdown?rev=b596e737286780d7bfa9fcddceaeeb754574b352#b596e737286780d7bfa9fcddceaeeb754574b352" dependencies = [ "cc", "tree-sitter-language", @@ -19646,9 +19818,11 @@ dependencies = [ "num-format", "schemars 1.0.4", "serde", + "settings", "smallvec", "strum 0.27.2", "theme", + "theme_settings", "ui_macros", "windows 0.61.3", ] @@ -19901,6 +20075,7 @@ dependencies = [ "log", "mach2 0.5.0", "nix 0.29.0", + "path", "percent-encoding", "pretty_assertions", "rand 0.9.4", @@ -20293,16 +20468,6 @@ dependencies = [ "leb128", ] -[[package]] -name = "wasm-encoder" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" -dependencies = [ - "leb128", - "wasmparser 0.221.3", -] - [[package]] name = "wasm-encoder" version = "0.227.1" @@ -20333,6 +20498,16 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + [[package]] name = "wasm-metadata" version = "0.201.0" @@ -20340,7 +20515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fd83062c17b9f4985d438603cde0a5e8c5c8198201a6937f778b607924c7da2" dependencies = [ "anyhow", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -20358,7 +20533,7 @@ dependencies = [ "anyhow", "auditable-serde", "flate2", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -20375,7 +20550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.11.4", + "indexmap 2.14.0", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -20412,58 +20587,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", ] [[package]] name = "wasmparser" -version = "0.221.3" +version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", - "serde", ] [[package]] name = "wasmparser" -version = "0.227.1" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", + "serde", ] [[package]] name = "wasmparser" -version = "0.236.1" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", - "serde", ] [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" dependencies = [ "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.11.4", + "hashbrown 0.17.1", + "indexmap 2.14.0", "semver", + "serde", ] [[package]] @@ -20479,9 +20654,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10306ead921db2c4645ff99867b7539b65e18afd8816d471547f5e6f3b09492" +checksum = "4b4442dc12aa2473def8334f0e0f2b489be52c52507c938bbdc8be69ded4ded6" dependencies = [ "addr2line", "anyhow", @@ -20492,7 +20667,7 @@ dependencies = [ "cfg-if", "encoding_rs", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "libc", "log", "mach2 0.4.3", @@ -20540,16 +20715,16 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7fb2c37ca263d444f33871bf0221e7de0707b2b2bb88165df6db6d58c73375f" +checksum = "5d881c3d6205898a226cc487b117f23b9ed1c7da39952d65bd5eeb6745b3789c" dependencies = [ "anyhow", "cpp_demangle", "cranelift-bitset", "cranelift-entity", "gimli", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "object", "postcard", @@ -20567,9 +20742,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-asm-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c6c0d3c8d2db554a3af8e8d413ff2815362ebce0911808ecfdaaa257438f93" +checksum = "5ab1876bcfa51d6a05dea1c13933f53cbc1e316c783fddebc859f56a736eae07" dependencies = [ "cfg-if", ] @@ -20586,9 +20761,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e3f3752466eb0e1f97149e53bf15c0e18ff520fc0a98b4bee1680e6de1c6f0" +checksum = "8ae1407944a0b13a8a77930b5b951aa7134beccecad7efac1ef9f03adb7d1a0f" dependencies = [ "anyhow", "proc-macro2", @@ -20601,15 +20776,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f54018baf62f4e9c616c31f2aeadcf0c202ff691a390ad53e291ae7160b169e" +checksum = "646a53678ce6aaf6f097e18ca51f650f2841aea6d2bcd7b61931397b8b8f30db" [[package]] name = "wasmtime-internal-cranelift" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a2412f2afb0a5db2a4ac1cfff73247e240aeaa90bf41497ad0a5084b6a24eca" +checksum = "ab3495aa8300e4ca6b53f81a53ce5eff6621fd5ff8378ef9ae552d1479d57371" dependencies = [ "anyhow", "cfg-if", @@ -20634,9 +20809,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecfdc460dd5d343d88ff1ffaf65ae019feeb6124ddcfd3f39d28331068d25b1f" +checksum = "29b5e4023a6b167da157338f5f0f505945eb45e78f1cac2d4dcce0922457d7d4" dependencies = [ "anyhow", "cc", @@ -20650,9 +20825,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5abb428a71827b7f90fc64406749883ccc6e58addf6d36974d5e06942011707" +checksum = "9da71e2d573e3cc6f753a3b7bff98f425ca060c0e8071cc55c3d867a9edf3ecc" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -20660,9 +20835,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6cc13f14c3fb83fb877cb1d5c605e93f7ec1bf7fc1a5e8b361209d2f8ca028" +checksum = "627d8f57909a4f9bb1dbe57a96229a54b89d5995353d0b321f3cb9a1a118977a" dependencies = [ "anyhow", "cfg-if", @@ -20672,24 +20847,24 @@ dependencies = [ [[package]] name = "wasmtime-internal-math" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb209473a09f4dbd9c87bb9f18b8dcb0c9da30d12a260e3eacf7a1a53b41480" +checksum = "45b99315585a8a27125dd9b0150edb115d6f6ff0baae453c21d30822aab77f00" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab4df5a04752106e1ecef9d40145ef28fa033b0d5dd3c839c9b208b2d522183" +checksum = "8eaee97281dd3fe47ec3d46c16fb9fe2dd32f37d0523c2d5c484f11b348734e4" [[package]] name = "wasmtime-internal-unwinder" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5359875d29bddb6f7e65e698157714d8d35ebd8ea2a92893d05d6b062147b639" +checksum = "d0c005f82c48492b6b44fa19ee5205bd933c4f8baca41e314eca8331dd3c4fd9" dependencies = [ "anyhow", "cfg-if", @@ -20700,9 +20875,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e247bcdd69701743ba386c933b26ebad2ce912ff9cb68b5b71fdb29d39ba04a" +checksum = "7b73639a9c0c0e33a2ef942ca99b6772b48393be92bebbd0767c607e5b0a68e0" dependencies = [ "proc-macro2", "quote", @@ -20711,9 +20886,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0298dfd9f57588222b5a92dcffe75894f1ead4e519850f176bde7fcfd105d54" +checksum = "392ca021d084c7426616ef77e1284315555f11bcbb34f416d74b0732db622811" dependencies = [ "anyhow", "cranelift-codegen", @@ -20728,22 +20903,22 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1706803e83b9bae726a0f55e7c1bbf78a7421cf2da68c940c70978e91dfc0339" +checksum = "7fd4703351476262d715b72431e80d10289908e3494050071d6521267f522d97" dependencies = [ "anyhow", "bitflags 2.10.0", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "wit-parser 0.236.1", ] [[package]] name = "wasmtime-wasi" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a430602ec54d0e32fbb61d2d8c7e5885eaa9dbc1664b6ed57fb57df439810a0" +checksum = "21921b6e8e8ed876a288cb3b0b3aee68809fed8182ce26c9977ffc4af4cb77f6" dependencies = [ "anyhow", "async-trait", @@ -20772,9 +20947,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2ba5dd68962de394cf15c7fb185f138cdd685ced631a7ed8e056de3e071029" +checksum = "2cceb2d110d8de61e7b0e8c501b838d3c6403a14cdca8cb612ff1228db537c6d" dependencies = [ "anyhow", "async-trait", @@ -20820,9 +20995,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -20906,9 +21081,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -21032,8 +21207,9 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -21061,8 +21237,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -21072,7 +21249,7 @@ dependencies = [ "cfg_aliases 0.2.1", "document-features", "hashbrown 0.16.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "naga", "once_cell", @@ -21093,32 +21270,36 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ "android_system_properties", "arrayvec", @@ -21170,8 +21351,9 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ "naga", "wgpu-types", @@ -21179,8 +21361,9 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -21250,9 +21433,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1979d3ed3ffc017538e518da6faa66b129f9229492981fc51004f28cb86db792" +checksum = "55a0751406b641ff50ef42d4a1ca843a03040c488c0c27f92093633447464013" dependencies = [ "anyhow", "async-trait", @@ -21265,9 +21448,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d92ae7a084d8543aa7ccef0fac52c86481a7278d0533f7fdeaf89bd7b7e29f" +checksum = "ab62083fdcecdd0cac61b8c46e7de4f2629ebe8699fd9ce790d922cc89d50f5f" dependencies = [ "anyhow", "heck 0.5.0", @@ -21279,9 +21462,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a1b1b93fd9ce569bb40c1eadf5c56533cebfc04ba545c8bc1e74464cff0735" +checksum = "756b7a4a7f57ee2f53e9ef3501ed0faacda4b8dcb169a921cddc8bc09ebd199e" dependencies = [ "proc-macro2", "quote", @@ -21322,9 +21505,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2d7ea2137be52644d9c42ca5a4899bba07c2ed2db1e66c4c1994adfe35d39e" +checksum = "61ec880b20caaa72245944b54cfb22aca111f8c805e12a7542b40d66921e5323" dependencies = [ "anyhow", "cranelift-assembler-x64", @@ -21884,6 +22067,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -22246,7 +22438,7 @@ checksum = "d8a39a15d1ae2077688213611209849cad40e9e5cccf6e61951a425850677ff3" dependencies = [ "anyhow", "heck 0.4.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "wasm-metadata 0.201.0", "wit-bindgen-core 0.22.0", "wit-component 0.201.0", @@ -22260,7 +22452,7 @@ checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata 0.227.1", @@ -22276,7 +22468,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata 0.244.0", @@ -22336,7 +22528,7 @@ checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22355,7 +22547,7 @@ checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22374,7 +22566,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22393,7 +22585,7 @@ checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22411,7 +22603,7 @@ checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22429,7 +22621,7 @@ checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22447,7 +22639,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22502,8 +22694,10 @@ dependencies = [ "project", "proptest", "proptest-derive", + "release_channel", "remote", "schemars 1.0.4", + "semver", "serde", "serde_json", "session", @@ -22652,11 +22846,12 @@ dependencies = [ [[package]] name = "xattr" -version = "0.2.3" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", + "rustix 1.1.2", ] [[package]] @@ -22752,7 +22947,7 @@ dependencies = [ "clap", "compliance", "gh-workflow", - "indexmap 2.11.4", + "indexmap 2.14.0", "indoc", "itertools 0.14.0", "regex", @@ -22963,7 +23158,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.10.0" +version = "1.14.0" dependencies = [ "acp_thread", "acp_tools", @@ -23050,6 +23245,7 @@ dependencies = [ "languages", "line_ending_selector", "log", + "lsp_locations", "markdown", "markdown_preview", "menu", diff --git a/Cargo.toml b/Cargo.toml index 80cc6501001485..324679cb185500 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/action_log", "crates/activity_indicator", "crates/agent", + "crates/agent_detect", "crates/agent_servers", "crates/agent_settings", "crates/agent_skills", @@ -129,6 +130,7 @@ members = [ "crates/llama_cpp", "crates/lmstudio", "crates/lsp", + "crates/lsp_locations", "crates/markdown", "crates/markdown_preview", "crates/mermaid_render", @@ -151,6 +153,7 @@ members = [ "crates/outline", "crates/outline_panel", "crates/panel", + "crates/path", "crates/paths", "crates/picker", "crates/picker_preview", @@ -273,6 +276,7 @@ acp_thread = { path = "crates/acp_thread" } action_log = { path = "crates/action_log" } activity_indicator = { path = "crates/activity_indicator" } agent = { path = "crates/agent" } +agent_detect = { path = "crates/agent_detect" } agent_servers = { path = "crates/agent_servers" } agent_settings = { path = "crates/agent_settings" } agent_skills = { path = "crates/agent_skills" } @@ -389,6 +393,7 @@ livekit_client = { path = "crates/livekit_client" } llama_cpp = { path = "crates/llama_cpp" } lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } +lsp_locations = { path = "crates/lsp_locations" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } mermaid_render = { path = "crates/mermaid_render" } @@ -414,6 +419,7 @@ outline = { path = "crates/outline" } outline_panel = { path = "crates/outline_panel" } panel = { path = "crates/panel" } paths = { path = "crates/paths" } +path = { path = "crates/path" } perf = { path = "tooling/perf" } picker = { path = "crates/picker" } picker_preview = { path = "crates/picker_preview" } @@ -504,11 +510,11 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -accesskit = "0.24.0" +accesskit = { version = "0.24.0", features = ["enumn"] } accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" accesskit_windows = "0.33.1" -agent-client-protocol = { version = "=1.0.1", features = ["unstable"] } +agent-client-protocol = { version = "=1.1.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" @@ -530,7 +536,9 @@ async-io = "2.6.0" async-lock = "3.4.2" async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "82d00a04211cf4e1236029aa03e6b6ce2a74c553" } async-recursion = "1.0.0" -async-tar = "0.5.1" +# `unstable` is required to compile async-tar's Windows symlink support. +async-std = { version = "1.12", features = ["unstable"] } +async-tar = "0.6" async-task = "4.7" async-trait = "0.1" async-tungstenite = "0.31.0" @@ -543,11 +551,13 @@ aws-credential-types = { version = "1.2.8", features = [ aws-sdk-bedrockruntime = { version = "1.112.0", features = [ "behavior-version-latest", ] } +aws-sigv4 = { version = "1.4.0", features = ["http1"] } aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" bitflags = "2.6.0" +block2 = "0.6" brotli = "8.0.2" bytes = "1.0" cargo_metadata = "0.19" @@ -565,7 +575,7 @@ cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" const_format = "0.2" convert_case = "0.11.0" -core-foundation = "=0.10.0" +core-foundation = "0.10" core-foundation-sys = "0.8.6" core-video = { version = "0.5.2", features = ["metal"] } cpal = "0.17" @@ -594,8 +604,9 @@ emojis = "0.6.1" env_logger = "0.11" encoding_rs = "0.8" exec = "0.3.1" -fancy-regex = "0.17.0" +fancy-regex = "0.18.0" fork = "0.4.0" +flume = "0.12" futures = "0.3.32" futures-concurrency = "7.7.1" futures-lite = "1.13" @@ -616,8 +627,26 @@ http-body = "1.0" httparse = "1.10" idna = "1.0" ignore = "0.4.22" -image = "0.25.1" -imara-diff = "0.1.8" +# image's default features minus "avif", which only adds an encoder (rav1e); +# decoding AVIF would additionally need the non-default "avif-native" feature. +image = { version = "0.25.1", default-features = false, features = [ + "bmp", + "dds", + "exr", + "ff", + "gif", + "hdr", + "ico", + "jpeg", + "png", + "pnm", + "qoi", + "rayon", + "tga", + "tiff", + "webp", +] } +imara-diff = "0.2.0" indexmap = { version = "2.7.0", features = ["serde"] } indoc = "2" inventory = "0.3.19" @@ -643,6 +672,7 @@ moka = { version = "0.12.10", features = ["sync"] } nanoid = "0.4" nbformat = "1.2.0" nix = "0.29" +notify-rust = "4" nucleo = "0.5" num-format = "0.4.4" objc = "0.2" @@ -674,6 +704,7 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "NSProcessInfo", "NSRange", "NSRunLoop", + "NSSet", "NSString", "NSURL", "NSUndoManager", @@ -681,6 +712,7 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "objc2-core-foundation", "std", ] } +objc2-user-notifications = "0.3" open = "5.0.0" ordered-float = "2.1.1" palette = { version = "0.7.5", default-features = false, features = ["std"] } @@ -715,6 +747,7 @@ quick-xml = "0.38" quote = "1.0.9" rand = "0.9" rayon = "1.8" +raw-window-handle = "0.6" regex = "1.5" # WARNING: If you change this, you must also publish a new version of zed-reqwest to crates.io reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ @@ -743,6 +776,7 @@ rustls-platform-verifier = "0.5.0" # WARNING: If you change this, you must also publish a new version of zed-scap to crates.io scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } schemars = { version = "1.0", features = ["indexmap2"] } +seccompiler = "0.5" semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0.221", features = ["derive", "rc"] } serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } @@ -793,7 +827,7 @@ toml_edit = { version = "0.22", default-features = false, features = [ "serde", ] } tower-http = "0.4.4" -tree-sitter = { version = "0.26.9", features = ["wasm"] } +tree-sitter = "0.26.9" tree-sitter-bash = "0.25.1" tree-sitter-c = "0.24.1" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } @@ -809,7 +843,7 @@ tree-sitter-heex = { git = "https://github.com/zed-industries/tree-sitter-heex", tree-sitter-html = "0.23" tree-sitter-jsdoc = "0.23" tree-sitter-json = "0.24" -tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-markdown", rev = "9a23c1a96c0513d8fc6520972beedd419a973539" } +tree-sitter-md = { git = "https://github.com/zed-industries/tree-sitter-markdown", rev = "b596e737286780d7bfa9fcddceaeeb754574b352" } # fork of 9a23c1a with serialize() buffer-overflow fix; https://github.com/tree-sitter-grammars/tree-sitter-markdown/issues/243 tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" @@ -828,8 +862,8 @@ usvg = { version = "0.46.0", default-features = false } uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } vte = { version = "0.15.0", features = ["ansi"] } walkdir = "2.5" -wasm-encoder = "0.221" -wasmparser = "0.221" +wasm-encoder = "0.252" +wasmparser = "0.252" wasmtime = { version = "36", default-features = false, features = [ "async", "demangle", @@ -845,22 +879,23 @@ which = "6.0.0" wasm-bindgen = "0.2.120" web-time = "1.1.0" webrtc-sys = "0.3.23" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } +wgpu = "29.0.4" windows-core = "0.61" yaml-rust2 = "0.8" yawc = "0.2.5" zeroize = "1.8" zstd = "0.11" - [workspace.dependencies.windows] version = "0.61" features = [ + "Data_Xml_Dom", "Foundation_Numerics", "Globalization_DateTimeFormatting", "Storage_Search", "Storage_Streams", "System_Threading", + "UI_Notifications", "UI_ViewManagement", "Wdk_System_SystemServices", "Win32_Foundation", @@ -882,6 +917,7 @@ features = [ "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", + "Win32_Storage_Packaging_Appx", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Console", @@ -1070,3 +1106,10 @@ ignored = [ "documented", "sea-orm-macros", ] + +# Dylint discovers our custom lints through this entry, so `cargo dylint --all` +# runs them without a `--path` argument. The `lints` package pins its own +# nightly toolchain (see `tooling/lints/rust-toolchain.toml`) and is kept out of +# this workspace on purpose. +[workspace.metadata.dylint] +libraries = [{ path = "tooling/lints" }] diff --git a/README.md b/README.md index 84f536569a32ce..43203074d41c7a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ +# superzed (Zed fork) + +> **NOTE (trademark):** "superzed" derives from the Zed name and may carry +> trademark/DMCA risk. A rename is deliberately deferred — do not invest in +> the name. + +This is a fork of [Zed](https://zed.dev) that reorients the editor around +agents. Where it diverges from upstream Zed: + +- **Sidebar** (`crates/sidebar`): a Workspaces panel (projects with git + branch and uncommitted `+/-` diff stats) and a Chats panel (agent threads + with relative age, agent label, and status filters) replace the title bar + and bottom dock. +- **Multi-workspace windows** (`crates/workspace/src/multi_workspace.rs`): + one window hosts N workspaces grouped by worktree ("project groups") with + quick project/thread switching (`NextProject`/`NextThread` and + cmd/ctrl-click). +- **Agent-first threads** (`crates/agent_ui`): threads are first-class tabs + with explicit run states — Running, parked-on-human (awaiting + confirmation/input), Idle — plus process-liveness detection, and a chat + header showing harness · model · cwd. +- **Persistent terminals**: terminal threads persist across restarts via + sidebar terminal-thread metadata instead of a terminal dock panel. +- The bottom dock and title bar are removed; panes render as rounded cards. + +Upstream README follows. + # Zed [![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev) diff --git a/assets/icons/ai_anthropic.svg b/assets/icons/ai_anthropic.svg index 12d731fb0b4438..91cc074db3083d 100644 --- a/assets/icons/ai_anthropic.svg +++ b/assets/icons/ai_anthropic.svg @@ -1,11 +1,4 @@ - - - - - - - - - + + diff --git a/assets/icons/ai_open_ai_compat.svg b/assets/icons/ai_open_ai_compat.svg index f6557caac33048..825d6885ef80e5 100644 --- a/assets/icons/ai_open_ai_compat.svg +++ b/assets/icons/ai_open_ai_compat.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/bolt_outlined.svg b/assets/icons/bolt_outlined.svg index ca9c75fbfd64be..935f24bd2fbbd0 100644 --- a/assets/icons/bolt_outlined.svg +++ b/assets/icons/bolt_outlined.svg @@ -1,3 +1,4 @@ - + + diff --git a/assets/icons/check_circle.svg b/assets/icons/check_circle.svg deleted file mode 100644 index f9b88c4ce1451e..00000000000000 --- a/assets/icons/check_circle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/circle_check.svg b/assets/icons/circle_check.svg deleted file mode 100644 index 8950aa7a0e1126..00000000000000 --- a/assets/icons/circle_check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/copilot.svg b/assets/icons/copilot.svg index 2584cd631006c1..6dc28b671a8bb8 100644 --- a/assets/icons/copilot.svg +++ b/assets/icons/copilot.svg @@ -1,9 +1,11 @@ - - - - - - - + + + + + + + + + diff --git a/assets/icons/debug.svg b/assets/icons/debug.svg index 6423a2b090c1b8..ec84706b13f177 100644 --- a/assets/icons/debug.svg +++ b/assets/icons/debug.svg @@ -1,12 +1,13 @@ - - - - - - - - - - + + + + + + + + + + + diff --git a/assets/icons/diff_split.svg b/assets/icons/diff_split.svg index 35a3e7072c290b..ebcd48cbb54fed 100644 --- a/assets/icons/diff_split.svg +++ b/assets/icons/diff_split.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/diff_split_auto.svg b/assets/icons/diff_split_auto.svg index f5828a6915d9ca..e5744dc56114a2 100644 --- a/assets/icons/diff_split_auto.svg +++ b/assets/icons/diff_split_auto.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/diff_unified.svg b/assets/icons/diff_unified.svg index f09628fda43b1f..61cc4bc0811299 100644 --- a/assets/icons/diff_unified.svg +++ b/assets/icons/diff_unified.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/fast_forward.svg b/assets/icons/fast_forward.svg index 240bc65aca3558..f5d6cbb5052e9b 100644 --- a/assets/icons/fast_forward.svg +++ b/assets/icons/fast_forward.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/assets/icons/fast_forward_off.svg b/assets/icons/fast_forward_off.svg index 8ea7c41c6582b0..218820dd47019b 100644 --- a/assets/icons/fast_forward_off.svg +++ b/assets/icons/fast_forward_off.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/file.svg b/assets/icons/file.svg index 60cf2537d9e673..037ade6827d2c2 100644 --- a/assets/icons/file.svg +++ b/assets/icons/file.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/file_code.svg b/assets/icons/file_code.svg index 548d5a153ba243..b07f1c36ebc9a5 100644 --- a/assets/icons/file_code.svg +++ b/assets/icons/file_code.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/file_diff.svg b/assets/icons/file_diff.svg index 193dd7392ff1ff..010a68b4ec6cc9 100644 --- a/assets/icons/file_diff.svg +++ b/assets/icons/file_diff.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/file_icons/folder_open.svg b/assets/icons/file_icons/folder_open.svg index f4ec13621e305e..68e388212350b0 100644 --- a/assets/icons/file_icons/folder_open.svg +++ b/assets/icons/file_icons/folder_open.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/file_icons/magnifying_glass.svg b/assets/icons/file_icons/magnifying_glass.svg index d0440d905c35bc..10f84017cd9823 100644 --- a/assets/icons/file_icons/magnifying_glass.svg +++ b/assets/icons/file_icons/magnifying_glass.svg @@ -1,3 +1,4 @@ - + + diff --git a/assets/icons/file_ignored.svg b/assets/icons/file_ignored.svg new file mode 100644 index 00000000000000..e15bf125db9c59 --- /dev/null +++ b/assets/icons/file_ignored.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_multiple.svg b/assets/icons/file_multiple.svg new file mode 100644 index 00000000000000..739885eb65ed69 --- /dev/null +++ b/assets/icons/file_multiple.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_text_filled.svg b/assets/icons/file_text_filled.svg index 15c81cca620917..91a4202ffa99fa 100644 --- a/assets/icons/file_text_filled.svg +++ b/assets/icons/file_text_filled.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/file_text_outlined.svg b/assets/icons/file_text_outlined.svg index d2e8897251e31b..f602bd0492eac8 100644 --- a/assets/icons/file_text_outlined.svg +++ b/assets/icons/file_text_outlined.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/assets/icons/filter.svg b/assets/icons/filter.svg index 4aa14e93c003d0..1ec7cbdb066d33 100644 --- a/assets/icons/filter.svg +++ b/assets/icons/filter.svg @@ -1,3 +1,10 @@ - + + + + + + + + diff --git a/assets/icons/folder_include.svg b/assets/icons/folder_include.svg index 83b5e6d1185534..69d67aa2cc1285 100644 --- a/assets/icons/folder_include.svg +++ b/assets/icons/folder_include.svg @@ -1,6 +1,5 @@ - - - - + + + diff --git a/assets/icons/folder_open.svg b/assets/icons/folder_open.svg index f4ec13621e305e..68e388212350b0 100644 --- a/assets/icons/folder_open.svg +++ b/assets/icons/folder_open.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/folder_share.svg b/assets/icons/folder_share.svg index 36db1414b8cf8f..898af2397c750e 100644 --- a/assets/icons/folder_share.svg +++ b/assets/icons/folder_share.svg @@ -1,5 +1,5 @@ - - + + diff --git a/assets/icons/folder_shared.svg b/assets/icons/folder_shared.svg index 785b3aa56d708b..897bef603a182a 100644 --- a/assets/icons/folder_shared.svg +++ b/assets/icons/folder_shared.svg @@ -1,6 +1,6 @@ - - + + diff --git a/assets/icons/generic_close.svg b/assets/icons/generic_close.svg index 0fd213daf9c81f..2326c2fcd56cfd 100644 --- a/assets/icons/generic_close.svg +++ b/assets/icons/generic_close.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/generic_maximize.svg b/assets/icons/generic_maximize.svg index f1d7da44efa954..a7db33feb62634 100644 --- a/assets/icons/generic_maximize.svg +++ b/assets/icons/generic_maximize.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/generic_minimize.svg b/assets/icons/generic_minimize.svg index 4b43cde2743e26..b39b215620f391 100644 --- a/assets/icons/generic_minimize.svg +++ b/assets/icons/generic_minimize.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/generic_restore.svg b/assets/icons/generic_restore.svg index d8a3d72bcd001a..c5cbcf3fd72513 100644 --- a/assets/icons/generic_restore.svg +++ b/assets/icons/generic_restore.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/git_worktree.svg b/assets/icons/git_worktree.svg index deb7172584fb43..af98f89591f56e 100644 --- a/assets/icons/git_worktree.svg +++ b/assets/icons/git_worktree.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/linux.svg b/assets/icons/linux.svg index fc76742a3f2366..9b44656c05c9f8 100644 --- a/assets/icons/linux.svg +++ b/assets/icons/linux.svg @@ -1,10 +1,15 @@ - - - + + + + + + + + - + diff --git a/assets/icons/list_filter.svg b/assets/icons/list_filter.svg deleted file mode 100644 index fc3885437da0b5..00000000000000 --- a/assets/icons/list_filter.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/magnifying_glass.svg b/assets/icons/magnifying_glass.svg index 24f00bb51bccc3..10f84017cd9823 100644 --- a/assets/icons/magnifying_glass.svg +++ b/assets/icons/magnifying_glass.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/on_call.svg b/assets/icons/on_call.svg new file mode 100644 index 00000000000000..66e5d71304fc9f --- /dev/null +++ b/assets/icons/on_call.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/repl_off.svg b/assets/icons/repl_off.svg deleted file mode 100644 index 3018ceaf8588cd..00000000000000 --- a/assets/icons/repl_off.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/repl_pause.svg b/assets/icons/repl_pause.svg deleted file mode 100644 index 5a69a576c1152d..00000000000000 --- a/assets/icons/repl_pause.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/icons/repl_play.svg b/assets/icons/repl_play.svg deleted file mode 100644 index 0c8f4b0832ba2d..00000000000000 --- a/assets/icons/repl_play.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/assets/icons/reply_arrow_right.svg b/assets/icons/reply_arrow_right.svg index d8321e8b3eb55c..69e74c4c233a3e 100644 --- a/assets/icons/reply_arrow_right.svg +++ b/assets/icons/reply_arrow_right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/sliders.svg b/assets/icons/sliders.svg deleted file mode 100644 index 20a6a367dc4963..00000000000000 --- a/assets/icons/sliders.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/icons/split.svg b/assets/icons/split.svg index b2be46a875b461..f762f21bc0e78b 100644 --- a/assets/icons/split.svg +++ b/assets/icons/split.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/square_dot.svg b/assets/icons/square_dot.svg index 72b32734399a2d..a56ced308616c1 100644 --- a/assets/icons/square_dot.svg +++ b/assets/icons/square_dot.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/square_minus.svg b/assets/icons/square_minus.svg index 5ba458e8b53bf6..31b2c97d74b98f 100644 --- a/assets/icons/square_minus.svg +++ b/assets/icons/square_minus.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/square_plus.svg b/assets/icons/square_plus.svg index 063c7dbf8261d9..88ada58011f40e 100644 --- a/assets/icons/square_plus.svg +++ b/assets/icons/square_plus.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/supermaven.svg b/assets/icons/supermaven.svg deleted file mode 100644 index af778c70b71c52..00000000000000 --- a/assets/icons/supermaven.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/icons/supermaven_disabled.svg b/assets/icons/supermaven_disabled.svg deleted file mode 100644 index 25eea54cdeffa8..00000000000000 --- a/assets/icons/supermaven_disabled.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/supermaven_error.svg b/assets/icons/supermaven_error.svg deleted file mode 100644 index a0a12e17c32963..00000000000000 --- a/assets/icons/supermaven_error.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/supermaven_init.svg b/assets/icons/supermaven_init.svg deleted file mode 100644 index 6851aad49dc7e7..00000000000000 --- a/assets/icons/supermaven_init.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/terminal_alt.svg b/assets/icons/terminal_alt.svg index d03c05423e24fa..fb47bacd6a920d 100644 --- a/assets/icons/terminal_alt.svg +++ b/assets/icons/terminal_alt.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/thinking_mode_off.svg b/assets/icons/thinking_mode_off.svg index e313950ce41303..ff74e4089e19c7 100644 --- a/assets/icons/thinking_mode_off.svg +++ b/assets/icons/thinking_mode_off.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/user_arrow_up.svg b/assets/icons/user_arrow_up.svg new file mode 100644 index 00000000000000..8320108fa79590 --- /dev/null +++ b/assets/icons/user_arrow_up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/user_group.svg b/assets/icons/user_group.svg index 30d2e5a7eac519..4783f2492a3c57 100644 --- a/assets/icons/user_group.svg +++ b/assets/icons/user_group.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/x_circle.svg b/assets/icons/x_circle.svg index 8807e5fa1fe691..6cd7912b3a7497 100644 --- a/assets/icons/x_circle.svg +++ b/assets/icons/x_circle.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/zed_agent_two.svg b/assets/icons/zed_agent_two.svg index c352be84d2f1be..9bf1affcbe1564 100644 --- a/assets/icons/zed_agent_two.svg +++ b/assets/icons/zed_agent_two.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/zed_assistant.svg b/assets/icons/zed_assistant.svg index 812277a100b7e6..8eb906b2657b05 100644 --- a/assets/icons/zed_assistant.svg +++ b/assets/icons/zed_assistant.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 959964b1a054e2..9571d44a827622 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -34,12 +34,6 @@ "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "ctrl-shift-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", - "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", @@ -152,6 +146,7 @@ "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", "alt-enter": "editor::OpenSelectionsInMultibuffer", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -319,6 +314,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-alt-pageup": "agent::ScrollOutputPageUp", "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", "ctrl-alt-home": "agent::ScrollOutputToTop", @@ -418,6 +414,7 @@ "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", "ctrl-l": "search::ToggleSelection", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -628,6 +625,13 @@ { "context": "Workspace", "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "ctrl-f6": "workspace::FocusNextPart", "alt-open": "projects::OpenRecent", // Change the default action on `menu::Confirm` by setting the parameter // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], @@ -665,6 +669,10 @@ "ctrl-b": "sidebar::ToggleSidebar", "ctrl-alt-j": "sidebar::ToggleSidebar", "ctrl-alt-;": "sidebar::FocusSidebar", + "ctrl-alt-]": "sidebar::NextProject", + "ctrl-alt-[": "sidebar::PreviousProject", + "ctrl-alt-shift-]": "sidebar::NextThread", + "ctrl-alt-shift-[": "sidebar::PreviousThread", "shift-find": "pane::DeploySearch", "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], @@ -1085,6 +1093,21 @@ "alt-shift-escape": "git::ToggleFillCommitEditor", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `ctrl-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "bindings": { @@ -1222,6 +1245,7 @@ "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], + "ctrl-q": ["terminal::SendKeystroke", "ctrl-q"], "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"], "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], @@ -1252,7 +1276,9 @@ }, { "context": "AgentPanel > Terminal", + "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, @@ -1270,13 +1296,6 @@ "ctrl-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1317,6 +1336,7 @@ "alt-down": "markdown::ScrollDownByItem", "ctrl-home": "markdown::ScrollToTop", "ctrl-end": "markdown::ScrollToBottom", + "ctrl-shift-v": "markdown::CloseAndReturnToEditor", "find": "buffer_search::Deploy", "ctrl-f": "buffer_search::Deploy", }, @@ -1508,7 +1528,8 @@ "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes", + "ctrl-shift-i": "branch_picker::CycleBranchFilter", + "ctrl-k": "branch_picker::ToggleFilterMenu", }, }, { @@ -1545,6 +1566,7 @@ "bindings": { "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", "ctrl-alt-shift-backspace": "worktree_picker::ForceDeleteWorktree", + "ctrl-shift-c": "zed::OpenWorktreeSetupTasks", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index cf100486cc6a84..458a1aaf83053c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -4,12 +4,6 @@ "use_key_equivalents": true, "bindings": { "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "shift-cmd-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", - "shift-f11": "debugger::StepOut", "home": "menu::SelectFirst", "shift-pageup": "menu::SelectFirst", "pageup": "menu::SelectFirst", @@ -169,12 +163,12 @@ "cmd-shift-enter": "editor::NewlineAbove", "cmd-k z": "editor::ToggleSoftWrap", "cmd-f": "buffer_search::Deploy", - "cmd-alt-f": "buffer_search::DeployReplace", "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }], "cmd-e": "buffer_search::UseSelectionForFind", "cmd->": "agent::AddSelectionToThread", "cmd-alt-e": "editor::SelectEnclosingSymbol", "alt-enter": "editor::OpenSelectionsInMultibuffer", + "alt-cmd-f": "text_finder::Toggle", }, }, { @@ -288,12 +282,6 @@ "alt-enter": "editor::Newline", }, }, - { - "context": "AgentConfiguration", - "bindings": { - "ctrl--": "pane::GoBack", - }, - }, { "context": "AcpThread > ModeSelector", "bindings": { @@ -363,6 +351,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "cmd-f": "agent::ToggleSearch", "ctrl-pageup": "agent::ScrollOutputPageUp", "ctrl-pagedown": "agent::ScrollOutputPageDown", "ctrl-home": "agent::ScrollOutputToTop", @@ -463,9 +452,9 @@ "cmd-shift-enter": "editor::ToggleFoldAll", "alt-enter": "search::SelectAllMatches", "cmd-f": "search::FocusSearch", - "cmd-alt-f": "search::ToggleReplace", "cmd-alt-l": "search::ToggleSelection", "cmd-shift-o": "outline::Toggle", + "alt-cmd-f": "text_finder::Toggle", }, }, { @@ -572,7 +561,6 @@ "alt-enter": "search::SelectAllMatches", "alt-cmd-c": "search::ToggleCaseSensitive", "alt-cmd-w": "search::ToggleWholeWord", - "alt-cmd-f": "project_search::ToggleFilters", "alt-cmd-x": "search::ToggleRegex", "cmd-k shift-enter": "pane::TogglePinTab", }, @@ -699,6 +687,13 @@ "context": "Workspace", "use_key_equivalents": true, "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `cmd-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "cmd-f6": "workspace::FocusNextPart", // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], "alt-cmd-o": "projects::OpenRecent", @@ -728,6 +723,10 @@ "cmd-r": "workspace::ToggleProjectPane", "cmd-alt-j": "sidebar::ToggleSidebar", "cmd-alt-;": "sidebar::FocusSidebar", + "ctrl-cmd-]": "sidebar::NextProject", + "ctrl-cmd-[": "sidebar::PreviousProject", + "ctrl-cmd-shift-]": "sidebar::NextThread", + "ctrl-cmd-shift-[": "sidebar::PreviousThread", "cmd-shift-f": "pane::DeploySearch", "cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "cmd-shift-t": "pane::ReopenClosedItem", @@ -1153,6 +1152,22 @@ "alt-tab": "git::GenerateCommitMessage", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `cmd-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "shift-cmd-f5": "debugger::RerunSession", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "bindings": { @@ -1330,6 +1345,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "cmd-f": "agent::ToggleSearch", "cmd-n": "agent::NewThread", }, }, @@ -1369,13 +1385,6 @@ "cmd-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1417,6 +1426,7 @@ "alt-down": "markdown::ScrollDownByItem", "cmd-up": "markdown::ScrollToTop", "cmd-down": "markdown::ScrollToBottom", + "cmd-shift-v": "markdown::CloseAndReturnToEditor", "cmd-f": "buffer_search::Deploy", }, }, @@ -1570,7 +1580,8 @@ "bindings": { "cmd-shift-backspace": "branch_picker::DeleteBranch", "cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch", - "cmd-shift-i": "branch_picker::FilterRemotes", + "cmd-shift-i": "branch_picker::CycleBranchFilter", + "cmd-k": "branch_picker::ToggleFilterMenu", }, }, { @@ -1608,6 +1619,7 @@ "bindings": { "cmd-shift-backspace": "worktree_picker::DeleteWorktree", "cmd-alt-shift-backspace": "worktree_picker::ForceDeleteWorktree", + "cmd-shift-c": "zed::OpenWorktreeSetupTasks", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 5c6ce6f6507a9f..4f632272fd7395 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -33,11 +33,6 @@ "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "ctrl-shift-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f10": "debugger::StepOver", - "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", "shift-alt-l": "lsp_tool::ToggleMenu", @@ -147,6 +142,7 @@ "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", "alt-enter": "editor::OpenSelectionsInMultibuffer", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -320,6 +316,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-alt-pageup": "agent::ScrollOutputPageUp", "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", "ctrl-alt-home": "agent::ScrollOutputToTop", @@ -420,6 +417,7 @@ "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", "ctrl-l": "search::ToggleSelection", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -626,6 +624,13 @@ "context": "Workspace", "use_key_equivalents": true, "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "ctrl-f6": "workspace::FocusNextPart", // Change the default action on `menu::Confirm` by setting the parameter // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], "ctrl-r": "projects::OpenRecent", @@ -1084,6 +1089,21 @@ "alt-shift-escape": "git::ToggleFillCommitEditor", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `ctrl-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f10": "debugger::StepOver", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "use_key_equivalents": true, @@ -1269,6 +1289,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, @@ -1294,13 +1315,6 @@ "ctrl-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1343,6 +1357,7 @@ "alt-down": "markdown::ScrollDownByItem", "ctrl-home": "markdown::ScrollToTop", "ctrl-end": "markdown::ScrollToBottom", + "ctrl-shift-v": "markdown::CloseAndReturnToEditor", "find": "buffer_search::Deploy", "ctrl-f": "buffer_search::Deploy", }, @@ -1490,7 +1505,8 @@ "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes", + "ctrl-shift-i": "branch_picker::CycleBranchFilter", + "ctrl-k": "branch_picker::ToggleFilterMenu", }, }, { @@ -1527,6 +1543,7 @@ "bindings": { "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", "ctrl-alt-shift-backspace": "worktree_picker::ForceDeleteWorktree", + "ctrl-shift-,": "zed::OpenWorktreeSetupTasks", }, }, { diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index f065b63ce40956..fd050f39364fda 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -185,6 +185,17 @@ }, { "context": "DebugPanel", "bindings": { "alt-5": "pane::CloseActiveItem" } }, { "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } }, + { + // `ctrl-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "ctrl-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, { "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", "bindings": { diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 2ec389aaa651fa..c35b3387d72ca0 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -189,6 +189,17 @@ }, { "context": "DebugPanel", "bindings": { "cmd-5": "pane::CloseActiveItem" } }, { "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } }, + { + // `cmd-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "cmd-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, { "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", "bindings": { diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json index 3fd1d6c87f85be..68cfb32f9b047c 100644 --- a/assets/keymaps/specific-overrides-macos.json +++ b/assets/keymaps/specific-overrides-macos.json @@ -11,6 +11,7 @@ "use_key_equivalents": true, "bindings": { "cmd-shift-a": "picker::ToggleActionsMenu", + "cmd-shift-s": "picker::ToggleMultiSelect", }, }, { @@ -28,6 +29,7 @@ "use_key_equivalents": true, "bindings": { "cmd-shift-p": "file_finder::SelectPrevious", + "tab": "picker::MultiSelectNext", "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", @@ -39,6 +41,10 @@ "use_key_equivalents": true, "bindings": { "alt-cmd-f": "text_finder::ToProjectSearch", + "tab": "picker::MultiSelectNext", + "alt-cmd-[": "text_finder::Fold", + "alt-cmd-]": "text_finder::Unfold", + "cmd-shift-enter": "text_finder::ToggleFoldAll", "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json index 663153a84357d9..494fd8d59d06e2 100644 --- a/assets/keymaps/specific-overrides.json +++ b/assets/keymaps/specific-overrides.json @@ -10,6 +10,7 @@ "context": "Picker > Editor", "bindings": { "ctrl-shift-a": "picker::ToggleActionsMenu", + "ctrl-shift-s": "picker::ToggleMultiSelect", }, }, { @@ -25,6 +26,7 @@ "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", "bindings": { "ctrl-shift-p": "file_finder::SelectPrevious", + "tab": "picker::MultiSelectNext", "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", @@ -35,10 +37,14 @@ "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", "bindings": { "ctrl-alt-f": "text_finder::ToProjectSearch", + "tab": "picker::MultiSelectNext", "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", "ctrl-l": "pane::SplitRight", + "ctrl-{": "text_finder::Fold", + "ctrl-}": "text_finder::Unfold", + "ctrl-shift-enter": "text_finder::ToggleFoldAll", }, }, ] diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json deleted file mode 100644 index 432bdc7004a4c6..00000000000000 --- a/assets/keymaps/storybook.json +++ /dev/null @@ -1,33 +0,0 @@ -[ - // Standard macOS bindings - { - "bindings": { - "home": "menu::SelectFirst", - "shift-pageup": "menu::SelectFirst", - "pageup": "menu::SelectFirst", - "cmd-up": "menu::SelectFirst", - "end": "menu::SelectLast", - "shift-pagedown": "menu::SelectLast", - "pagedown": "menu::SelectLast", - "cmd-down": "menu::SelectLast", - "tab": "menu::SelectNext", - "ctrl-n": "menu::SelectNext", - "down": "menu::SelectNext", - "shift-tab": "menu::SelectPrevious", - "ctrl-p": "menu::SelectPrevious", - "up": "menu::SelectPrevious", - "enter": "menu::Confirm", - "ctrl-enter": "menu::SecondaryConfirm", - "cmd-enter": "menu::SecondaryConfirm", - "ctrl-escape": "menu::Cancel", - "cmd-escape": "menu::Cancel", - "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel", - "cmd-q": "storybook::Quit", - "backspace": "editor::Backspace", - "delete": "editor::Delete", - "left": "editor::MoveLeft", - "right": "editor::MoveRight", - }, - }, -] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 940a0cc195acd5..ecdbf51337b2bc 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -550,6 +550,9 @@ "space y": "editor::Copy", "space /": "pane::DeploySearch", + // View mode + "z c": "editor::ScrollCursorCenter", + // Debug mode (Helix space G) "space shift-g l": "debugger::Start", "space shift-g r": "debugger::Restart", @@ -1007,6 +1010,7 @@ "ctrl-d": "project_panel::ScrollDown", "z t": "project_panel::ScrollCursorTop", "z z": "project_panel::ScrollCursorCenter", + "z c": "project_panel::ScrollCursorCenter", "z b": "project_panel::ScrollCursorBottom", "0": ["vim::Number", 0], "1": ["vim::Number", 1], @@ -1038,6 +1042,7 @@ "ctrl-d": "outline_panel::ScrollDown", "z t": "outline_panel::ScrollCursorTop", "z z": "outline_panel::ScrollCursorCenter", + "z c": "outline_panel::ScrollCursorCenter", "z b": "outline_panel::ScrollCursorBottom", "0": ["vim::Number", 0], "1": ["vim::Number", 1], diff --git a/assets/settings/default.json b/assets/settings/default.json index 9ff8d473403f1d..c7ebf10eb1f802 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -75,7 +75,7 @@ "agent_buffer_font_size": 13, // The default font size for the commit editor in the git panel and commit modal. "git_commit_buffer_font_size": 12, - // The default font size for the markdown preview. Falls back to the editor font size if unset. + // The default font size for the markdown preview. Falls back to the UI font size if unset. "markdown_preview_font_size": null, // The font family for the markdown preview. Falls back to the UI font family if unset. "markdown_preview_font_family": null, @@ -163,8 +163,7 @@ // May take 2 values: // 1. Open directories as a new workspace in the current Zed window's sidebar // "cli_default_open_behavior": "existing_window" - // 2. Open directories in a new window (reuse existing windows for files - // that are already part of an open project) + // 2. Open paths in a new window, unless they are subpaths of an existing project // "cli_default_open_behavior": "new_window" "cli_default_open_behavior": "existing_window", // The default behavior when opening projects from the UI. @@ -235,6 +234,9 @@ // // Default: "client" "window_decorations": "client", + // Whether to optimize Zed's interface for assistive technology such as screen + // readers. + "accessible_mode": false, // Whether to use the system provided dialogs for Open and Save As. // When set to false, Zed will use the built-in keyboard-first pickers. "use_system_path_prompts": true, @@ -267,6 +269,15 @@ // 3. Hide on typing and on key bindings that resolve to an action: // "on_typing_and_action" "hide_mouse": "on_typing_and_action", + // Whether to reduce non-essential motion in the UI, such as loading + // spinners and pulsating labels, by rendering them in a static state. + // + // May take 2 values: + // 1. Always reduce motion: + // "on" + // 2. Never reduce motion: + // "off" + "reduce_motion": "off", // Determines whether the focused panel follows the mouse location. "focus_follows_mouse": { "enabled": false, @@ -400,6 +411,14 @@ // 4. Preserve the cursor's vertical position within the viewport, falling back to `center` when the cursor is // offscreen: `preserve` "go_to_definition_scroll_strategy": "center", + // Where to show LSP results that can contain multiple locations + // (Go to Definition, Go to Implementation, Find All References). A single + // result always opens directly. Individual actions can override this with + // their `open_results_in` argument. + // + // 1. Open the results in a multibuffer: `multi_buffer` (default) + // 2. Open the results in a filterable picker: `picker` + "lsp_results_location": "multi_buffer", // Which level to use to filter out diagnostics displayed in the editor. // // Affects the editor rendering only, and does not interrupt @@ -1012,11 +1031,6 @@ // Default: project_diff "entry_primary_click_action": "project_diff", }, - "message_editor": { - // Whether to automatically replace emoji shortcodes with emoji characters. - // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true, - }, "sidebar": { // Where to position the sidebar. Can be 'left' or 'right'. "side": "left", @@ -1474,7 +1488,13 @@ // The EditorConfig `end_of_line` property overrides this setting and behaves // like `enforce_lf` or `enforce_crlf`. "line_ending": "detect", - // Whether or not to perform a buffer format before saving: [on, off] + // Whether or not to perform a buffer format before saving: + // "on" — format the whole buffer + // "off" — do not format + // "modifications" — format only lines with unstaged changes; skips formatting + // when no git diff is available or the language server lacks range formatting + // "modifications_if_available" — same, but falls back to formatting the whole + // buffer when range formatting cannot be used // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored "format_on_save": "off", // How to perform a buffer format. This setting can take multiple values: @@ -1913,6 +1933,12 @@ "copy_on_select": false, // Whether to keep the text selection after copying it to the clipboard. "keep_selection_on_copy": true, + // Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even + // when the terminal application has enabled mouse reporting (e.g. vim with + // mouse=a, htop). When false, these clicks are forwarded to the application + // instead, and hyperlinks can still be opened with shift-cmd-click + // (shift-ctrl-click). + "open_links_in_mouse_mode": true, // Whether to show the terminal button in the status bar "button": true, // Any key-value pairs added to this list will be added to the terminal's diff --git a/assets/settings/default_semantic_token_rules.json b/assets/settings/default_semantic_token_rules.json index c070a253d3065f..354fb431ebb30e 100644 --- a/assets/settings/default_semantic_token_rules.json +++ b/assets/settings/default_semantic_token_rules.json @@ -119,20 +119,10 @@ "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": [], - "style": ["variable"], + "style": ["variable.parameter"], }, { "token_type": "variable", diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index bb6c9c04ae14db..416383e0f7eaac 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -55,5 +55,8 @@ "save": "none", // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] + // Hooks that cause this task to run automatically on certain events: + // * `create_worktree` — run this task after creating a new git worktree (e.g. to install dependencies or copy untracked config files into it) + // "hooks": ["create_worktree"] }, ] diff --git a/assets/settings/initial_worktree_setup_tasks.json b/assets/settings/initial_worktree_setup_tasks.json new file mode 100644 index 00000000000000..5a3c5a45078ce5 --- /dev/null +++ b/assets/settings/initial_worktree_setup_tasks.json @@ -0,0 +1,23 @@ +// Worktree setup tasks, stored in this project's tasks.json. +// See https://zed.dev/docs/tasks#hooks for the full Tasks Hooks documentation. +// +// Tasks with the `create_worktree` hook run automatically right after Zed +// creates a new git worktree. Use them to get a fresh worktree ready to work +// in — for example, installing dependencies or copying files that git doesn't +// track (like `.env`) from the original repository. +// +// Two variables are available to these tasks: +// * $ZED_WORKTREE_ROOT — the root directory of the newly created worktree +// * $ZED_MAIN_GIT_WORKTREE — the original repository's working directory +// +// Uncomment the example below and adjust the command to get started. +// Note: hooked tasks run as soon as a worktree is created, so only enable +// commands you want to run automatically. +[ + // { + // "label": "Set up new worktree", + // "command": "cp -n \"$ZED_MAIN_GIT_WORKTREE/.env\" \"$ZED_WORKTREE_ROOT/\" && npm install", + // "cwd": "$ZED_WORKTREE_ROOT", + // "hooks": ["create_worktree"] + // } +] diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index f27566c4f72cac..ee5073e7a44f78 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -387,6 +387,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d2a6ffff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#5ac1feff", "font_style": null, @@ -789,6 +794,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#a37accff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#3b9ee5ff", "font_style": null, @@ -1191,6 +1201,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#dfbfffff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#72cffeff", "font_style": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 4330df54fccae5..75252f781dadc6 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -397,6 +397,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -814,6 +819,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -1231,6 +1241,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -1648,6 +1663,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, @@ -2065,6 +2085,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, @@ -2482,6 +2507,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index b6b4dfd0c6357a..698b8febceaf53 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -394,6 +394,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d07277ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#bf956aff", "font_style": null, @@ -806,6 +811,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d3604fff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#ad6e25ff", "font_style": null, diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2f495df96ae17d..c74af99d91df95 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -176,9 +176,7 @@ pub struct SandboxAuthorizationDetails { /// builds still render the network request. #[serde(default, alias = "network")] pub network_all_hosts: bool, - /// Whether the command requested access to protected `.git` directories. - #[serde(default)] - pub allow_git_access: bool, + #[serde(default)] pub allow_fs_write_all: bool, #[serde(default)] @@ -226,6 +224,11 @@ pub struct SandboxFallbackAuthorizationDetails { /// whether to run the command without a sandbox. #[serde(default)] pub reason: String, + /// Slug of the sandboxing docs section that best explains how to fix this + /// failure (see [`crate::LinuxWslSandboxError::docs_section`]), rendered as a + /// "Learn more" link. `None` when the cause is unknown. + #[serde(default)] + pub docs_section: Option, } pub fn meta_with_sandbox_fallback_authorization( @@ -387,10 +390,357 @@ pub enum AgentThreadEntry { UserMessage(UserMessage), AssistantMessage(AssistantMessage), ToolCall(ToolCall), + Elicitation(ElicitationEntryId), CompletedPlan(Vec), ContextCompaction(ContextCompaction), } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ElicitationEntryId(pub Arc); + +#[derive(Debug)] +pub struct Elicitation { + pub id: ElicitationEntryId, + pub request: acp::CreateElicitationRequest, + pub status: ElicitationStatus, +} + +#[derive(Debug)] +pub enum ElicitationStatus { + Pending { + respond_tx: oneshot::Sender, + }, + Accepted, + Declined, + Canceled, + Completed, +} + +#[derive(Clone, Debug)] +pub enum ElicitationStoreEvent { + ElicitationRequested(ElicitationEntryId), + ElicitationResponded(ElicitationEntryId), + ElicitationUpdated(ElicitationEntryId), +} + +#[derive(Default)] +pub struct ElicitationStore { + elicitations: Vec, +} + +impl EventEmitter for ElicitationStore {} + +impl ElicitationStore { + pub fn elicitations(&self) -> &[Elicitation] { + &self.elicitations + } + + fn validate_request(request: &acp::CreateElicitationRequest) -> Result<(), acp::Error> { + if let acp::ElicitationMode::Url(mode) = &request.mode { + url::Url::parse(&mode.url) + .map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?; + } + + Ok(()) + } + + fn insert_pending_elicitation( + &mut self, + request: acp::CreateElicitationRequest, + ) -> ( + ElicitationEntryId, + oneshot::Receiver, + ) { + let (respond_tx, response_rx) = oneshot::channel(); + let id = ElicitationEntryId(Uuid::new_v4().to_string().into()); + self.elicitations.push(Elicitation { + id: id.clone(), + request, + status: ElicitationStatus::Pending { respond_tx }, + }); + (id, response_rx) + } + + fn response_task( + id: ElicitationEntryId, + response_rx: oneshot::Receiver, + cx: &mut Context, + emit_responded: impl FnOnce(&mut T, &mut Context, ElicitationEntryId) + 'static, + ) -> Task + where + T: 'static, + { + cx.spawn(async move |this, cx| { + let response = response_rx.await.unwrap_or_else(|oneshot::Canceled| { + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel) + }); + this.update(cx, |this, cx| emit_responded(this, cx, id)) + .ok(); + response + }) + } + + fn respond_to_elicitation_entry( + elicitation: &mut Elicitation, + response: acp::CreateElicitationResponse, + ) -> bool { + if !matches!(elicitation.status, ElicitationStatus::Pending { .. }) { + return false; + } + let ElicitationStatus::Pending { respond_tx } = mem::replace( + &mut elicitation.status, + elicitation_status_for_response(&response), + ) else { + return false; + }; + respond_tx.send(response).ok(); + true + } + + fn complete_url_elicitation_entry(elicitation: &mut Elicitation) -> bool { + let previous_status = mem::replace(&mut elicitation.status, ElicitationStatus::Completed); + match previous_status { + ElicitationStatus::Pending { respond_tx } => { + respond_tx + .send(acp::CreateElicitationResponse::new( + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()), + )) + .ok(); + true + } + ElicitationStatus::Accepted => true, + ElicitationStatus::Completed => false, + previous_status @ (ElicitationStatus::Declined | ElicitationStatus::Canceled) => { + elicitation.status = previous_status; + false + } + } + } + + fn cancel_elicitation_entry( + elicitation: &mut Elicitation, + cancel_accepted_url_elicitations: bool, + ) -> bool { + match mem::replace(&mut elicitation.status, ElicitationStatus::Canceled) { + ElicitationStatus::Pending { respond_tx } => { + respond_tx + .send(acp::CreateElicitationResponse::new( + acp::ElicitationAction::Cancel, + )) + .ok(); + true + } + ElicitationStatus::Accepted + if cancel_accepted_url_elicitations + && matches!(&elicitation.request.mode, acp::ElicitationMode::Url(_)) => + { + true + } + previous_status => { + elicitation.status = previous_status; + false + } + } + } + + fn respond_to_elicitation_by_id( + &mut self, + id: &ElicitationEntryId, + response: acp::CreateElicitationResponse, + ) -> bool { + let Some((_, elicitation)) = self.elicitation_mut(id) else { + return false; + }; + Self::respond_to_elicitation_entry(elicitation, response) + } + + fn complete_url_elicitation_by_id(&mut self, id: &ElicitationEntryId) -> bool { + let Some((_, elicitation)) = self.elicitation_mut(id) else { + return false; + }; + Self::complete_url_elicitation_entry(elicitation) + } + + fn cancel_elicitation_by_id( + &mut self, + id: &ElicitationEntryId, + cancel_accepted_url_elicitations: bool, + ) -> bool { + let Some((_, elicitation)) = self.elicitation_mut(id) else { + return false; + }; + Self::cancel_elicitation_entry(elicitation, cancel_accepted_url_elicitations) + } + + pub fn request_elicitation( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result, acp::Error> { + self.request_elicitation_with_id(request, cx) + .map(|(_, task)| task) + } + + pub fn request_elicitation_with_id( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result<(ElicitationEntryId, Task), acp::Error> { + Self::validate_request(&request)?; + let (id, response_rx) = self.insert_pending_elicitation(request); + cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone())); + cx.notify(); + + let task = Self::response_task(id.clone(), response_rx, cx, |_store, cx, id| { + cx.emit(ElicitationStoreEvent::ElicitationResponded(id)); + cx.notify(); + }); + + Ok((id, task)) + } + + pub fn respond_to_elicitation( + &mut self, + id: &ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + if !self.respond_to_elicitation_by_id(id, response) { + return; + } + + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone())); + cx.notify(); + } + + pub fn complete_url_elicitation( + &mut self, + elicitation_id: &acp::ElicitationId, + cx: &mut Context, + ) { + let Some(entry_id) = self.entry_id_for_url_elicitation(elicitation_id) else { + return; + }; + if !self.complete_url_elicitation_by_id(&entry_id) { + return; + } + + cx.emit(ElicitationStoreEvent::ElicitationUpdated(entry_id)); + cx.notify(); + } + + pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context) { + if !self.cancel_elicitation_by_id(id, true) { + return; + } + + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone())); + cx.notify(); + } + + pub fn cancel_all(&mut self, cx: &mut Context) { + let canceled_ids = self.cancel_pending(|_| true); + for id in canceled_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id)); + } + cx.notify(); + } + + pub fn clear(&mut self, cx: &mut Context) { + let canceled_ids = self.cancel_pending(|_| true); + self.elicitations.clear(); + for id in canceled_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id)); + } + cx.notify(); + } + + pub fn clear_resolved(&mut self, cx: &mut Context) -> Vec { + let mut cleared_ids = Vec::new(); + self.elicitations.retain(|elicitation| { + let keep = matches!( + (&elicitation.status, &elicitation.request.mode), + (ElicitationStatus::Pending { .. }, _) + | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_)) + ); + if !keep { + cleared_ids.push(elicitation.id.clone()); + } + keep + }); + + if !cleared_ids.is_empty() { + for id in &cleared_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone())); + } + cx.notify(); + } + + cleared_ids + } + + pub fn cancel_request(&mut self, request_id: &acp::RequestId, cx: &mut Context) { + let canceled_ids = self.cancel_pending(|elicitation| { + matches!( + elicitation.request.scope(), + acp::ElicitationScope::Request(scope) if &scope.request_id == request_id + ) + }); + for id in canceled_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id)); + } + cx.notify(); + } + + pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> { + self.elicitations + .iter() + .enumerate() + .rev() + .find_map(|(index, elicitation)| { + (&elicitation.id == id).then_some((index, elicitation)) + }) + } + + fn entry_id_for_url_elicitation( + &self, + elicitation_id: &acp::ElicitationId, + ) -> Option { + self.elicitations.iter().rev().find_map(|elicitation| { + if let acp::ElicitationMode::Url(mode) = &elicitation.request.mode + && &mode.elicitation_id == elicitation_id + { + Some(elicitation.id.clone()) + } else { + None + } + }) + } + + fn elicitation_mut(&mut self, id: &ElicitationEntryId) -> Option<(usize, &mut Elicitation)> { + self.elicitations + .iter_mut() + .enumerate() + .rev() + .find_map(|(index, elicitation)| { + (&elicitation.id == id).then_some((index, elicitation)) + }) + } + + fn cancel_pending( + &mut self, + mut should_cancel: impl FnMut(&Elicitation) -> bool, + ) -> Vec { + let mut canceled_ids = Vec::new(); + for elicitation in &mut self.elicitations { + if should_cancel(elicitation) && Self::cancel_elicitation_entry(elicitation, true) { + canceled_ids.push(elicitation.id.clone()); + } + } + canceled_ids + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextCompactionId(pub Arc); @@ -432,6 +782,7 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.indented, Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, + Self::Elicitation(_) => false, Self::CompletedPlan(_) => false, Self::ContextCompaction(_) => false, } @@ -442,6 +793,7 @@ 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::Elicitation(_) => "## Input Requested\n\n".to_string(), Self::CompletedPlan(entries) => { let mut md = String::from("## Plan\n\n"); for entry in entries { @@ -971,6 +1323,15 @@ impl Display for ToolCallStatus { } } +fn elicitation_status_for_response(response: &acp::CreateElicitationResponse) -> ElicitationStatus { + match &response.action { + acp::ElicitationAction::Accept(_) => ElicitationStatus::Accepted, + acp::ElicitationAction::Decline => ElicitationStatus::Declined, + acp::ElicitationAction::Cancel => ElicitationStatus::Canceled, + _ => ElicitationStatus::Canceled, + } +} + #[derive(Debug, PartialEq, Clone)] pub enum ContentBlock { Empty, @@ -1102,10 +1463,16 @@ impl ContentBlock { }; let new_content = &text_content.text; markdown.update(cx, |markdown, cx| { - let current = markdown.source().to_string(); - match new_content.strip_prefix(¤t) { - Some("") => {} - Some(suffix) => markdown.append(suffix, cx), + // Compare against the source in place; copying it out would cost a + // full O(len) allocation on every streamed snapshot. + let prefix_len = if new_content.starts_with(markdown.source().as_str()) { + Some(markdown.source().len()) + } else { + None + }; + match prefix_len { + Some(len) if len == new_content.len() => {} + Some(len) => markdown.append(&new_content[len..], cx), None => markdown.reset(new_content.clone().into(), cx), } }); @@ -1721,6 +2088,7 @@ pub struct AcpThread { title: Option, provisional_title: Option, entries: Vec, + elicitations: ElicitationStore, plan: Plan, project: Entity, action_log: Entity, @@ -1739,6 +2107,9 @@ pub struct AcpThread { pending_terminal_output: HashMap>>, pending_terminal_exit: HashMap, had_error: bool, + /// Set when the agent server process backing this thread exits, so the UI + /// can distinguish "agent is dead" from ordinary errors or idleness. + server_exit_status: Option, /// The user's unsent prompt text, persisted so it can be restored when reloading the thread. draft_prompt: Option>, /// The initial scroll position for the thread view, set during session registration. @@ -1757,6 +2128,9 @@ struct StreamingTextBuffer { bytes_to_reveal_per_tick: usize, /// The Markdown entity being streamed into. target: Entity, + /// Index of the thread entry containing `target`, used to emit + /// `EntryUpdated` when buffered text is actually revealed. + entry_index: usize, /// Timer task that periodically moves text from `pending` into `source`. _reveal_task: Task<()>, } @@ -1789,6 +2163,8 @@ pub enum AcpThreadEvent { EntriesRemoved(Range), ToolAuthorizationRequested(acp::ToolCallId), ToolAuthorizationReceived(acp::ToolCallId), + ElicitationRequested(ElicitationEntryId), + ElicitationResponded(ElicitationEntryId), Retry(RetryStatus), SubagentSpawned(acp::SessionId), Stopped(acp::StopReason), @@ -1932,6 +2308,7 @@ impl AcpThread { update_last_checkpoint_if_changed_task: None, shared_buffers: Default::default(), entries: Default::default(), + elicitations: ElicitationStore::default(), plan: Default::default(), title, provisional_title: None, @@ -1949,6 +2326,7 @@ impl AcpThread { pending_terminal_output: HashMap::default(), pending_terminal_exit: HashMap::default(), had_error: false, + server_exit_status: None, draft_prompt: None, ui_scroll_position: None, streaming_text_buffer: None, @@ -2076,6 +2454,16 @@ impl AcpThread { self.had_error } + /// Whether the agent server process backing this thread is still alive. + /// Threads served in-process (e.g. the native agent) always report `true`. + pub fn server_alive(&self) -> bool { + self.server_exit_status.is_none() && self.connection.server_alive() + } + + pub fn server_exit_status(&self) -> Option { + self.server_exit_status + } + pub fn is_waiting_for_confirmation(&self) -> bool { for entry in self.entries.iter().rev() { match entry { @@ -2084,7 +2472,17 @@ impl AcpThread { status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) => return true, + AgentThreadEntry::Elicitation(elicitation_id) + if self.elicitations.elicitation(elicitation_id).is_some_and( + |(_, elicitation)| { + matches!(elicitation.status, ElicitationStatus::Pending { .. }) + }, + ) => + { + return true; + } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -2114,6 +2512,7 @@ impl AcpThread { return true; } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -2134,6 +2533,7 @@ impl AcpThread { return true; } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -2149,7 +2549,8 @@ impl AcpThread { AgentThreadEntry::UserMessage(..) => return false, AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) - | AgentThreadEntry::ContextCompaction(_) => continue, + | AgentThreadEntry::ContextCompaction(_) + | AgentThreadEntry::Elicitation(_) => continue, AgentThreadEntry::ToolCall(..) => return true, } } @@ -2394,9 +2795,13 @@ impl AcpThread { if let Some(markdown) = self.streaming_markdown_target(message_id.as_ref(), is_thought, indented) { - let entries_len = self.entries.len(); - cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); - self.buffer_streaming_text(&markdown, text_content.text.clone(), cx); + // Don't emit EntryUpdated here: the chunk only lands in the + // pending buffer, so subscribers would resync an entry whose + // content hasn't changed yet. The reveal tick and flush emit + // it when text is actually appended, coalescing per-chunk + // event fanout down to at most one event per reveal tick. + let entry_index = self.entries.len() - 1; + self.buffer_streaming_text(&markdown, entry_index, text_content.text.clone(), cx); return; } } @@ -2521,12 +2926,14 @@ impl AcpThread { fn buffer_streaming_text( &mut self, markdown: &Entity, + entry_index: usize, text: String, cx: &mut Context, ) { if let Some(buffer) = &mut self.streaming_text_buffer { if buffer.target.entity_id() == markdown.entity_id() { buffer.pending.push_str(&text); + buffer.entry_index = entry_index; buffer.bytes_to_reveal_per_tick = (buffer.pending.len() as f32 / StreamingTextBuffer::REVEAL_TARGET @@ -2547,6 +2954,7 @@ impl AcpThread { pending: text, bytes_to_reveal_per_tick: bytes_to_reveal, target, + entry_index, _reveal_task, }); } @@ -2561,6 +2969,7 @@ impl AcpThread { buffer .target .update(cx, |markdown, cx| markdown.append(&buffer.pending, cx)); + cx.emit(AcpThreadEvent::EntryUpdated(buffer.entry_index)); } } } @@ -2596,6 +3005,7 @@ impl AcpThread { markdown.append(&buffer.pending[..byte_boundary], cx); buffer.pending.drain(..byte_boundary); }); + cx.emit(AcpThreadEvent::EntryUpdated(buffer.entry_index)); true }) @@ -3089,6 +3499,99 @@ impl AcpThread { cx.emit(AcpThreadEvent::EntryUpdated(ix)); } + pub fn request_elicitation( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result, acp::Error> { + self.request_elicitation_with_id(request, cx) + .map(|(_, task)| task) + } + + pub fn request_elicitation_with_id( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result<(ElicitationEntryId, Task), acp::Error> { + ElicitationStore::validate_request(&request)?; + + let (id, response_rx) = self.elicitations.insert_pending_elicitation(request); + self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx); + cx.emit(AcpThreadEvent::ElicitationRequested(id.clone())); + + let task = + ElicitationStore::response_task(id.clone(), response_rx, cx, |_thread, cx, id| { + cx.emit(AcpThreadEvent::ElicitationResponded(id)) + }); + + Ok((id, task)) + } + + pub fn respond_to_elicitation( + &mut self, + id: &ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + let Some(ix) = self.elicitation_entry_ix(id) else { + return; + }; + if !self.elicitations.respond_to_elicitation_by_id(id, response) { + return; + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn complete_url_elicitation( + &mut self, + elicitation_id: &acp::ElicitationId, + cx: &mut Context, + ) { + let Some(entry_id) = self + .elicitations + .entry_id_for_url_elicitation(elicitation_id) + else { + return; + }; + let Some(ix) = self.elicitation_entry_ix(&entry_id) else { + return; + }; + if !self.elicitations.complete_url_elicitation_by_id(&entry_id) { + return; + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context) { + let Some(ix) = self.elicitation_entry_ix(id) else { + return; + }; + if !self.elicitations.cancel_elicitation_by_id(id, true) { + return; + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + fn elicitation_entry_ix(&self, id: &ElicitationEntryId) -> Option { + self.entries + .iter() + .enumerate() + .rev() + .find_map(|(index, entry)| { + matches!(entry, AgentThreadEntry::Elicitation(elicitation_id) if elicitation_id == id) + .then_some(index) + }) + } + + pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> { + let index = self.elicitation_entry_ix(id)?; + let (_, elicitation) = self.elicitations.elicitation(id)?; + Some((index, elicitation)) + } + pub fn plan(&self) -> &Plan { &self.plan } @@ -3296,10 +3799,12 @@ impl AcpThread { // state even when the send_task is cancelled before tx.send(). if is_same_turn { this.running_turn.take(); - cx.emit(AcpThreadEvent::StatusChanged); } let Ok(response) = response else { + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } // tx dropped, just return return Ok(None); }; @@ -3309,6 +3814,9 @@ impl AcpThread { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); if r.stop_reason == acp::StopReason::MaxTokens { + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } this.had_error = true; cx.emit(AcpThreadEvent::Error); log::error!("Max tokens reached. Usage: {:?}", this.token_usage); @@ -3328,14 +3836,14 @@ impl AcpThread { log::error!("Max tokens reached. Usage: {:?}", this.token_usage); } if is_same_turn { - this.mark_pending_entries_as_canceled(cx); + this.cancel_pending_turn_entries(cx); } return Err(anyhow!(MaxOutputTokensError)); } let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); if canceled && is_same_turn { - this.mark_pending_entries_as_canceled(cx); + this.cancel_pending_turn_entries(cx); } if !canceled { @@ -3387,13 +3895,19 @@ impl AcpThread { cx.emit(AcpThreadEvent::TokenUsageUpdated); } + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } cx.emit(AcpThreadEvent::Stopped(r.stop_reason)); Ok(Some(r)) } Err(e) => { + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); if is_same_turn { - this.mark_pending_entries_as_canceled(cx); + this.cancel_pending_turn_entries(cx); } this.had_error = true; cx.emit(AcpThreadEvent::Error); @@ -3407,19 +3921,25 @@ impl AcpThread { } pub fn cancel(&mut self, cx: &mut Context) -> Task<()> { + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); + self.cancel_outstanding_elicitations(cx); + let Some(turn) = self.running_turn.take() else { return Task::ready(()); }; - self.connection.cancel(&self.session_id, cx); - - Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); self.mark_pending_entries_as_canceled(cx); + self.connection.cancel(&self.session_id, cx); cx.emit(AcpThreadEvent::StatusChanged); // Wait for the send task to complete cx.background_spawn(turn.send_task) } + fn cancel_pending_turn_entries(&mut self, cx: &mut Context) { + self.mark_pending_entries_as_canceled(cx); + self.cancel_outstanding_elicitations(cx); + } + fn mark_pending_entries_as_canceled(&mut self, cx: &mut Context) { for (ix, entry) in self.entries.iter_mut().enumerate() { match entry { @@ -3446,6 +3966,20 @@ impl AcpThread { } } + fn cancel_outstanding_elicitations(&mut self, cx: &mut Context) { + for ix in 0..self.entries.len() { + let Some(AgentThreadEntry::Elicitation(elicitation_id)) = self.entries.get(ix) else { + continue; + }; + if self + .elicitations + .cancel_elicitation_by_id(elicitation_id, true) + { + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + } + } + /// Restores the git working tree to the state at the given checkpoint (if one exists) pub fn restore_checkpoint( &mut self, @@ -3621,12 +4155,16 @@ impl AcpThread { return Ok(()); }; - let equal = git_store + let Some(equal) = git_store .update(cx, |git, cx| { git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx) }) .await - .unwrap_or(true); + .context("failed to compare checkpoints") + .log_err() + else { + return Ok(()); + }; this.update(cx, |this, cx| { if let Some((ix, message)) = this.user_message_mut(&client_id) { @@ -3952,17 +4490,18 @@ impl AcpThread { let (task_command, task_args) = builder .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); - let (task_command, task_args, task_env, sandbox) = prepare_sandbox_wrap( - task_command, - task_args, - cwd.clone(), - sandbox_wrap, - env, - ) - .await?; - (task_command, task_args, task_env, sandbox, cwd.clone()) - }; - let terminal = project + let (task_command, task_args, task_env, sandbox) = cx + .background_spawn(prepare_sandbox_wrap( + task_command, + task_args, + cwd.clone(), + sandbox_wrap, + env, + )) + .await?; + (task_command, task_args, task_env, sandbox, cwd.clone()) + }; + let terminal = project .update(cx, |project, cx| { project.create_terminal_task( task::SpawnInTerminal { @@ -4039,10 +4578,25 @@ impl AcpThread { } pub fn to_markdown(&self, cx: &App) -> String { - self.entries.iter().map(|e| e.to_markdown(cx)).collect() + self.entries + .iter() + .map(|entry| match entry { + AgentThreadEntry::Elicitation(elicitation_id) => self + .elicitations + .elicitation(elicitation_id) + .map(|(_, elicitation)| { + format!("## Input Requested\n\n{}\n\n", elicitation.request.message) + }) + .unwrap_or_else(|| entry.to_markdown(cx)), + _ => entry.to_markdown(cx), + }) + .collect() } pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context) { + if let LoadError::Exited { status, .. } = &error { + self.server_exit_status = Some(*status); + } cx.emit(AcpThreadEvent::LoadError(error)); } @@ -4118,7 +4672,8 @@ impl AcpThread { } if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) { - entity.update(cx, |_term, cx| { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, _| inner.shrink_to_used()); cx.notify(); }); } @@ -4154,7 +4709,8 @@ impl AcpThread { status, } => { if let Some(entity) = self.terminals.get(&terminal_id) { - entity.update(cx, |_term, cx| { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, _| inner.shrink_to_used()); cx.notify(); }); } else { @@ -4213,11 +4769,13 @@ fn markdown_for_raw_output( mod tests { use super::*; use anyhow::anyhow; + use feature_flags::FeatureFlag as _; use futures::stream::StreamExt as _; use futures::{channel::mpsc, future::LocalBoxFuture, select}; + use gpui::UpdateGlobal as _; use gpui::{App, AsyncApp, TestAppContext, WeakEntity}; use indoc::indoc; - use project::{AgentId, FakeFs, Fs}; + use project::{AgentId, FakeFs, Fs, RemoveOptions}; use rand::{distr, prelude::*}; use serde_json::json; use settings::SettingsStore; @@ -4272,11 +4830,31 @@ mod tests { fn init_test(cx: &mut TestAppContext) { env_logger::try_init().ok(); cx.update(|cx| { - let settings_store = SettingsStore::test(cx); + let mut settings_store = SettingsStore::test(cx); + settings_store.register_setting::(); cx.set_global(settings_store); }); } + fn enable_acp_beta(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + } + + fn set_acp_beta_override(value: &str, cx: &mut TestAppContext) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string()); + }); + }); + }); + } + #[test] fn text_resource_markdown_uses_mime_type_for_code_blocks() { let shell = acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview") @@ -4561,6 +5139,83 @@ mod tests { ); } + #[gpui::test] + async fn test_terminal_exit_preserves_visible_scrollback(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) + .await + .unwrap(); + + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); + let lower = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ); + builder.subscribe(cx) + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "Buffered Test".to_string(), + cwd: None, + output_byte_limit: None, + terminal: lower.clone(), + }, + cx, + ); + }); + + let mut output = String::new(); + for line in 0..15_000 { + output.push_str(&format!("line {line}\n")); + } + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: output.into_bytes(), + }, + cx, + ); + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id.clone(), + status: acp::TerminalExitStatus::new().exit_code(0), + }, + cx, + ); + }); + + let content = thread.read_with(cx, |thread, cx| { + let term = thread.terminal(terminal_id.clone()).unwrap(); + term.read_with(cx, |term, cx| term.inner().read(cx).get_content()) + }); + + assert!( + content.contains("line 14999"), + "expected output to remain visible after terminal exit, got: {content}" + ); + } + #[gpui::test] async fn test_terminal_output_and_exit_buffered_before_created(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -5141,6 +5796,146 @@ mod tests { }); } + #[gpui::test] + async fn test_streaming_text_coalesces_entry_updated_events(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let entry_updated_count = Rc::new(RefCell::new(0_usize)); + let _subscription = cx.update(|cx| { + cx.subscribe(&thread, { + let entry_updated_count = entry_updated_count.clone(); + move |_, event, _| { + if let AcpThreadEvent::EntryUpdated(_) = event { + *entry_updated_count.borrow_mut() += 1; + } + } + }) + }); + + const CHUNK_COUNT: usize = 1000; + let started = std::time::Instant::now(); + for i in 0..CHUNK_COUNT { + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + format!("chunk {i} ").into(), + )), + cx, + ) + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(1)); + } + // Let the smooth-streaming reveal buffer drain completely. + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + let elapsed = started.elapsed(); + + let expected: String = (0..CHUNK_COUNT).map(|i| format!("chunk {i} ")).collect(); + thread.read_with(cx, |thread, cx| { + assert_eq!(thread.entries().len(), 1); + let AgentThreadEntry::AssistantMessage(message) = &thread.entries()[0] else { + panic!("expected assistant entry"); + }; + assert_eq!(message.chunks.len(), 1); + let AssistantMessageChunk::Message { block, .. } = &message.chunks[0] else { + panic!("expected message chunk"); + }; + assert_eq!(block.to_markdown(cx), expected); + }); + + let count = *entry_updated_count.borrow(); + eprintln!( + "streamed {CHUNK_COUNT} chunks in {elapsed:?}; EntryUpdated emitted {count} times" + ); + // Reveal ticks fire every 16ms of virtual time (~125 over the 2s + // simulated above), so a coalesced implementation stays far below one + // event per streamed chunk. Before coalescing this was ~1 per chunk. + assert!( + count < CHUNK_COUNT / 4, + "EntryUpdated fanout not coalesced: {count} events for {CHUNK_COUNT} chunks" + ); + } + + #[gpui::test] + async fn test_flush_streaming_text_emits_entry_updated(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + cx.subscribe(&thread, { + let events = events.clone(); + move |_, event: &AcpThreadEvent, _| match event { + AcpThreadEvent::EntryUpdated(ix) => { + events.borrow_mut().push(format!("updated({ix})")) + } + AcpThreadEvent::NewEntry => events.borrow_mut().push("new".to_string()), + _ => {} + } + }) + }); + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Hello ".into())), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("world".into())), + cx, + ) + .unwrap(); + }); + + // The second chunk is buffered for smooth streaming: no EntryUpdated + // is emitted until the buffered text is actually revealed or flushed. + assert_eq!(events.borrow().as_slice(), ["new"]); + + // Pushing a tool call flushes the streaming buffer synchronously; the + // flush must emit EntryUpdated for the message entry so views remeasure + // the appended text. + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::ToolCall(acp::ToolCall::new("tool_1", "List files")), + cx, + ) + .unwrap(); + }); + + assert_eq!(events.borrow().as_slice(), ["new", "updated(0)", "new"]); + thread.read_with(cx, |thread, cx| { + let AgentThreadEntry::AssistantMessage(message) = &thread.entries()[0] else { + panic!("expected assistant entry"); + }; + assert_eq!(message.to_markdown(cx), "## Assistant\n\nHello world\n\n"); + }); + } + #[gpui::test] async fn test_thinking_concatenation(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -6757,71 +7552,1286 @@ mod tests { }); } - async fn run_until_first_tool_call( - thread: &Entity, - cx: &mut TestAppContext, - ) -> usize { - let (mut tx, mut rx) = mpsc::channel::(1); - - let subscription = cx.update(|cx| { - cx.subscribe(thread, move |thread, _, cx| { - for (ix, entry) in thread.read(cx).entries.iter().enumerate() { - if matches!(entry, AgentThreadEntry::ToolCall(_)) { - return tx.try_send(ix).unwrap(); - } - } - }) - }); + async fn new_test_thread(cx: &mut TestAppContext) -> Entity { + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + cx.update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap() + } - select! { - _ = futures::FutureExt::fuse(cx.background_executor.timer(Duration::from_secs(10))) => { - panic!("Timeout waiting for tool call") - } - ix = rx.next().fuse() => { - drop(subscription); - ix.unwrap() - } - } + fn only_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) { + let [entry] = thread.entries() else { + panic!("expected one elicitation entry, got {:?}", thread.entries()); + }; + let AgentThreadEntry::Elicitation(id) = entry else { + panic!("expected one elicitation entry, got {:?}", thread.entries()); + }; + let Some((_, elicitation)) = thread.elicitation(id) else { + panic!("missing elicitation entry"); + }; + (id.clone(), elicitation) } - #[derive(Clone, Default)] - struct FakeAgentConnection { - auth_methods: Vec, - supports_truncate: bool, - sessions: Arc>>>, - set_title_calls: Rc>>, - on_user_message: Option< - Rc< - dyn Fn( - acp::PromptRequest, - WeakEntity, - AsyncApp, - ) -> LocalBoxFuture<'static, Result> - + 'static, - >, - >, + fn latest_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) { + let Some(AgentThreadEntry::Elicitation(id)) = thread.entries().last() else { + panic!("expected latest entry to be an elicitation"); + }; + let Some((_, elicitation)) = thread.elicitation(id) else { + panic!("missing elicitation entry"); + }; + (id.clone(), elicitation) } - impl FakeAgentConnection { - fn new() -> Self { - Self { - auth_methods: Vec::new(), - supports_truncate: true, - on_user_message: None, - sessions: Arc::default(), - set_title_calls: Default::default(), - } - } + #[gpui::test] + async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![]); + }); + set_acp_beta_override("off", cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); - fn without_truncate_support(mut self) -> Self { - self.supports_truncate = false; - self - } + let result = thread.update(cx, |thread, cx| { + thread.request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + }); - #[expect(unused)] - fn with_auth_methods(mut self, auth_methods: Vec) -> Self { - self.auth_methods = auth_methods; - self + assert!(result.is_ok()); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries(), + [AgentThreadEntry::Elicitation(_)] + )); + }); + } + + #[gpui::test] + async fn test_form_elicitation_accepts_response(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let tool_call_id = acp::ToolCallId::new("tool-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id.clone()) + .tool_call_id(tool_call_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, elicitation) = only_thread_elicitation(thread); + let acp::ElicitationScope::Session(scope) = elicitation.request.scope() else { + panic!("expected session-scoped elicitation"); + }; + assert_eq!(scope.tool_call_id.as_ref(), Some(&tool_call_id)); + elicitation_id + }); + + let expected_content = std::collections::BTreeMap::from([( + "name".to_string(), + acp::ElicitationContentValue::from("Ada"), + )]); + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content.clone()), + )), + cx, + ); + }); + + let response = response_task.await; + assert_eq!( + response.action, + acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content) + ) + ); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Accepted)); + }); + } + + #[gpui::test] + async fn test_url_elicitation_can_be_completed(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = only_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Completed)); + }); + } + + #[gpui::test] + async fn test_idle_cancel_cancels_accepted_url_elicitation(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = only_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + + thread.update(cx, |thread, cx| { + thread.cancel(cx).detach(); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_accepted_url_elicitation_marks_canceled(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = only_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Accepted)); + }); + + thread.update(cx, |thread, cx| { + thread.cancel(cx).detach(); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_turn_cancel_cancels_accepted_url_elicitation_from_previous_turn( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let prompt_count = Rc::new(RefCell::new(0usize)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let prompt_count = prompt_count.clone(); + move |_request, _thread, _cx| { + let stop_reason = { + let mut prompt_count = prompt_count.borrow_mut(); + let stop_reason = if *prompt_count == 0 { + acp::StopReason::EndTurn + } else { + acp::StopReason::Cancelled + }; + *prompt_count += 1; + stop_reason + }; + + async move { Ok(acp::PromptResponse::new(stop_reason)) }.boxed_local() + } + })); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("new session should succeed"); + + let response = thread + .update(cx, |thread, cx| thread.send(vec!["first turn".into()], cx)) + .await + .expect("first turn should succeed") + .expect("first turn should return a response"); + assert_eq!(response.stop_reason, acp::StopReason::EndTurn); + + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .expect("url elicitation should be accepted") + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = latest_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + + let response = thread + .update(cx, |thread, cx| thread.send(vec!["second turn".into()], cx)) + .await + .expect("second turn should succeed") + .expect("second turn should return a response"); + assert_eq!(response.stop_reason, acp::StopReason::Cancelled); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_request_scoped_elicitation_store_accepts_response(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one elicitation entry, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, acp::RequestId::Number(1)); + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); + }); + } + + #[gpui::test] + async fn test_request_elicitation_store_ignores_duplicate_response(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one elicitation entry, got {:?}", + store.elicitations() + ); + }; + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); + }); + } + + #[gpui::test] + async fn test_cancel_session_elicitation_by_id_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let (elicitation_id, response_task) = thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + thread.update(cx, |thread, cx| { + thread.cancel_elicitation(&elicitation_id, cx); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_pending_session_elicitation_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, _) = only_thread_elicitation(thread); + elicitation_id + }); + + thread.update(cx, |thread, cx| { + thread.cancel(cx).detach(); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + fn request_test_session_elicitation( + thread: WeakEntity, + session_id: acp::SessionId, + cx: &mut AsyncApp, + ) -> Result> { + thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .map_err(|error| anyhow!(error)) + })? + } + + #[gpui::test] + async fn test_prompt_error_cancels_pending_session_elicitation(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let elicitation_action = Rc::new(RefCell::new(None)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let elicitation_action = elicitation_action.clone(); + move |request, thread, mut cx| { + let elicitation_action = elicitation_action.clone(); + async move { + let response_task = + request_test_session_elicitation(thread, request.session_id, &mut cx)?; + cx.spawn(async move |_cx| { + let response = response_task.await; + *elicitation_action.borrow_mut() = Some(response.action); + }) + .detach(); + + Err(anyhow!("prompt failed")) + } + .boxed_local() + } + })); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("new session should succeed"); + + let result = thread + .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)) + .await; + + assert!(result.is_err()); + cx.run_until_parked(); + assert_eq!( + *elicitation_action.borrow(), + Some(acp::ElicitationAction::Cancel) + ); + thread.read_with(cx, |thread, _| { + let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry { + AgentThreadEntry::Elicitation(id) => { + thread.elicitation(id).map(|(_, elicitation)| elicitation) + } + _ => None, + }) else { + panic!("expected an elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_max_tokens_cancels_pending_session_elicitation(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let elicitation_action = Rc::new(RefCell::new(None)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let elicitation_action = elicitation_action.clone(); + move |request, thread, mut cx| { + let elicitation_action = elicitation_action.clone(); + async move { + let response_task = + request_test_session_elicitation(thread, request.session_id, &mut cx)?; + cx.spawn(async move |_cx| { + let response = response_task.await; + *elicitation_action.borrow_mut() = Some(response.action); + }) + .detach(); + + Ok(acp::PromptResponse::new(acp::StopReason::MaxTokens)) + } + .boxed_local() + } + })); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("new session should succeed"); + + let result = thread + .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)) + .await; + + assert!(result.is_err()); + cx.run_until_parked(); + assert_eq!( + *elicitation_action.borrow(), + Some(acp::ElicitationAction::Cancel) + ); + thread.read_with(cx, |thread, _| { + let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry { + AgentThreadEntry::Elicitation(id) => { + thread.elicitation(id).map(|(_, elicitation)| elicitation) + } + _ => None, + }) else { + panic!("expected an elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_request_scoped_elicitation_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let (elicitation_id, response_task) = store.update(cx, |store, cx| { + store + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + store.update(cx, |store, cx| { + store.cancel_elicitation(&elicitation_id, cx); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_request_elicitation_store_cancel_all_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + store.update(cx, |store, cx| { + store.cancel_all(cx); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + } + + #[gpui::test] + async fn test_request_elicitation_store_clear_removes_answered_and_cancels_pending( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let first_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + let second_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(2)), + acp::ElicitationSchema::new().string("account", true), + ), + "Provide an account", + ), + cx, + ) + .unwrap() + }); + + let first_elicitation_id = store.read_with(cx, |store, _| { + let [first, _second] = store.elicitations() else { + panic!("expected two elicitations, got {:?}", store.elicitations()); + }; + first.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &first_elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + store.clear(cx); + }); + + assert_eq!( + first_response_task.await.action, + acp::ElicitationAction::Decline + ); + assert_eq!( + second_response_task.await.action, + acp::ElicitationAction::Cancel + ); + store.read_with(cx, |store, _| assert!(store.elicitations().is_empty())); + } + + #[gpui::test] + async fn test_request_elicitation_store_clear_resolved_preserves_outstanding( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let accepted_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + let pending_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(2)), + acp::ElicitationSchema::new().string("account", true), + ), + "Provide an account", + ), + cx, + ) + .unwrap() + }); + let accepted_url_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(3)), + url_elicitation_id, + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let (accepted_id, pending_id, accepted_url_id) = store.read_with(cx, |store, _| { + let [accepted, pending, accepted_url] = store.elicitations() else { + panic!( + "expected three request-scoped elicitations, got {:?}", + store.elicitations() + ); + }; + ( + accepted.id.clone(), + pending.id.clone(), + accepted_url.id.clone(), + ) + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &accepted_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + store.respond_to_elicitation( + &accepted_url_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + accepted_response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + assert!(matches!( + accepted_url_response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + + let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx)); + assert_eq!(cleared_ids, vec![accepted_id]); + store.read_with(cx, |store, _| { + let [pending, accepted_url] = store.elicitations() else { + panic!( + "expected pending and accepted url elicitations, got {:?}", + store.elicitations() + ); + }; + assert_eq!(pending.id, pending_id); + assert!(matches!(pending.status, ElicitationStatus::Pending { .. })); + assert_eq!(accepted_url.id, accepted_url_id); + assert!(matches!(accepted_url.status, ElicitationStatus::Accepted)); + }); + + store.update(cx, |store, cx| store.clear(cx)); + assert_eq!( + pending_response_task.await.action, + acp::ElicitationAction::Cancel + ); + } + + #[gpui::test] + async fn test_request_url_elicitation_store_can_be_completed(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.complete_url_elicitation(&url_elicitation_id, cx); + }); + + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Completed)); + }); + } + + #[gpui::test] + async fn test_request_url_elicitation_store_cancel_all_cancels_accepted_url( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one elicitation entry, got {:?}", + store.elicitations() + ); + }; + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + store.update(cx, |store, cx| { + store.cancel_all(cx); + }); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + store.update(cx, |store, cx| { + store.complete_url_elicitation(&url_elicitation_id, cx); + }); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_pending_elicitations_preserves_responded_statuses( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, _) = only_thread_elicitation(thread); + elicitation_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + thread.cancel(cx).detach(); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); + }); + } + + #[gpui::test] + async fn test_session_elicitation_ignores_duplicate_response(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, _) = only_thread_elicitation(thread); + elicitation_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); + }); + } + + #[gpui::test] + async fn test_url_elicitation_rejects_invalid_url(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let result = thread.update(cx, |thread, cx| { + thread.request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + "url-1", + "not a url", + ), + "Complete this in the browser", + ), + cx, + ) + }); + + assert!(result.is_err()); + thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty())); + } + + async fn run_until_first_tool_call( + thread: &Entity, + cx: &mut TestAppContext, + ) -> usize { + let (mut tx, mut rx) = mpsc::channel::(1); + + let subscription = cx.update(|cx| { + cx.subscribe(thread, move |thread, _, cx| { + for (ix, entry) in thread.read(cx).entries.iter().enumerate() { + if matches!(entry, AgentThreadEntry::ToolCall(_)) { + return tx.try_send(ix).unwrap(); + } + } + }) + }); + + select! { + _ = futures::FutureExt::fuse(cx.background_executor.timer(Duration::from_secs(10))) => { + panic!("Timeout waiting for tool call") + } + ix = rx.next().fuse() => { + drop(subscription); + ix.unwrap() + } + } + } + + #[derive(Clone, Default)] + struct FakeAgentConnection { + auth_methods: Vec, + supports_truncate: bool, + sessions: Arc>>>, + set_title_calls: Rc>>, + on_user_message: Option< + Rc< + dyn Fn( + acp::PromptRequest, + WeakEntity, + AsyncApp, + ) -> LocalBoxFuture<'static, Result> + + 'static, + >, + >, + } + + impl FakeAgentConnection { + fn new() -> Self { + Self { + auth_methods: Vec::new(), + supports_truncate: true, + on_user_message: None, + sessions: Arc::default(), + set_title_calls: Default::default(), + } + } + + fn without_truncate_support(mut self) -> Self { + self.supports_truncate = false; + self + } + + #[expect(unused)] + fn with_auth_methods(mut self, auth_methods: Vec) -> Self { + self.auth_methods = auth_methods; + self } fn on_user_message( @@ -7424,6 +9434,84 @@ mod tests { ); } + /// This is a regression test for a bug where update_last_checkpoint would + /// swallow a checkpoint comparison error and hide an already-visible + /// "Restore checkpoint" button without logging anything. + #[gpui::test] + async fn test_update_last_checkpoint_compare_error_keeps_checkpoint_visible( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({".git": {}, "file.txt": "content"})) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/test"))], cx).await; + + // The handler waits for this signal so the repository can be swapped + // out while the turn is still running. + let (complete_tx, complete_rx) = futures::channel::oneshot::channel::<()>(); + let complete_rx = RefCell::new(Some(complete_rx)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message( + move |_, _thread, _cx| { + let complete_rx = complete_rx.borrow_mut().take(); + async move { + if let Some(rx) = complete_rx { + rx.await.ok(); + } + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + }, + )); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let send_future = thread.update(cx, |thread, cx| thread.send_raw("message", cx)); + let send_task = cx.background_executor.spawn(send_future); + cx.run_until_parked(); + + // Show the checkpoint, as update_last_checkpoint_if_changed does when + // files change during the turn. + thread.update(cx, |thread, _| { + let (_, message) = thread.last_user_message().unwrap(); + message.checkpoint.as_mut().unwrap().show = true; + }); + + // Recreate `.git` so the git store reopens the repository. The fresh + // fake repository doesn't contain the checkpoint recorded at send + // time, so the end-of-turn comparison fails. + fs.remove_dir( + Path::new(path!("/test/.git")), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + cx.run_until_parked(); + fs.create_dir(Path::new(path!("/test/.git"))).await.unwrap(); + cx.run_until_parked(); + + complete_tx.send(()).unwrap(); + send_task.await.unwrap(); + cx.run_until_parked(); + + thread.update(cx, |thread, _| { + let (_, message) = thread.last_user_message().unwrap(); + assert!( + message.checkpoint.as_ref().unwrap().show, + "a checkpoint comparison failure must not hide the restore checkpoint button" + ); + }); + } + /// Tests that when a follow-up message is sent during generation, /// the first turn completing does NOT clear `running_turn` because /// it now belongs to the second turn. diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 71c46d3e62272f..abe29811eb59af 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -1,10 +1,10 @@ -use crate::AcpThread; +use crate::{AcpThread, ElicitationStore}; use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; use gpui::{Entity, SharedString, Task}; -use language_model::{DisabledReason, LanguageModelProviderId}; +use language_model::DisabledReason; use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc}; @@ -97,6 +97,13 @@ pub trait AgentConnection { None } + /// Whether the agent server process behind this connection (if any) is + /// still running. Connections that are not backed by a subprocess always + /// report `true`. + fn server_alive(&self) -> bool { + true + } + fn new_session( self: Rc, project: Entity, @@ -201,6 +208,13 @@ pub trait AgentConnection { fn cancel(&self, session_id: &acp::SessionId, cx: &mut App); + /// Request-scoped elicitations are connection-level because they can arrive before a session + /// thread exists. Session-scoped elicitations stay in the thread timeline, but use + /// `ElicitationStore` for shared processing. + fn request_elicitations(&self) -> Option> { + None + } + fn truncate( &self, _session_id: &acp::SessionId, @@ -414,26 +428,17 @@ impl dyn AgentSessionList { #[derive(Debug)] pub struct AuthRequired { pub description: Option, - pub provider_id: Option, } impl AuthRequired { pub fn new() -> Self { - Self { - description: None, - provider_id: None, - } + Self { description: None } } pub fn with_description(mut self, description: String) -> Self { self.description = Some(description); self } - - pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self { - self.provider_id = Some(provider_id); - self - } } impl Error for AuthRequired {} @@ -763,6 +768,7 @@ mod test_support { supports_session_additional_directories: bool, agent_id: AgentId, telemetry_id: SharedString, + server_alive: Arc, } struct Session { @@ -786,9 +792,16 @@ mod test_support { supports_session_additional_directories: false, agent_id: AgentId::new("stub"), telemetry_id: "stub".into(), + server_alive: Arc::new(std::sync::atomic::AtomicBool::new(true)), } } + /// Simulates the agent server process dying (or coming back) so tests + /// can exercise liveness-dependent UI states. + pub fn set_server_alive(&self, alive: bool) { + self.server_alive.store(alive, Ordering::SeqCst); + } + pub fn set_next_prompt_updates(&self, updates: Vec) { *self.next_prompt_updates.lock() = updates; } @@ -905,6 +918,10 @@ mod test_support { self.telemetry_id.clone() } + fn server_alive(&self) -> bool { + self.server_alive.load(Ordering::SeqCst) + } + fn auth_methods(&self) -> &[acp::AuthMethod] { &[] } @@ -956,7 +973,9 @@ mod test_support { _method_id: acp::AuthMethodId, _cx: &mut App, ) -> Task> { - unimplemented!() + Task::ready(Err(anyhow::anyhow!( + "StubAgentConnection has no auth methods" + ))) } fn prompt( diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index d297b5fa98f513..0834d7eabbab84 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -177,7 +177,7 @@ impl Diff { }; format!( "Diff: {}\n```\n{}\n```\n", - path.unwrap_or("untitled".into()), + path.unwrap_or(MultiBuffer::DEFAULT_TITLE.into()), buffer_text ) } @@ -260,7 +260,7 @@ impl PendingDiff { let path = new_buffer .file() .map(|file| file.path().display(file.path_style(cx))) - .unwrap_or("untitled".into()) + .unwrap_or(MultiBuffer::DEFAULT_TITLE.into()) .into(); let replica_id = new_buffer.replica_id(); diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b5e6ab90ab9686..3686f2955c1abf 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -83,33 +83,6 @@ impl MentionUri { .and_then(|input| input.strip_suffix('`')) .unwrap_or(input); - fn parse_line_range(fragment: &str) -> Result> { - let range = fragment.strip_prefix("L").unwrap_or(fragment); - - let (start, end) = if let Some((start, end)) = range.split_once(":") { - (start, end) - } else if let Some((start, end)) = range.split_once("-") { - // Also handle L10-20 or L10-L20 format - (start, end.strip_prefix("L").unwrap_or(end)) - } else { - // Single line number like L1872 - treat as a range of one line - (range, range) - }; - - let start_line = start - .parse::() - .context("Parsing line range start")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; - let end_line = end - .parse::() - .context("Parsing line range end")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; - - Ok(start_line..=end_line) - } - let parse_column = |input: Option| -> Option { input?.parse::().ok()?.checked_sub(1) }; let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> { @@ -121,37 +94,6 @@ impl MentionUri { Ok(()) }; - let parse_absolute_path = |input: &str| -> Result { - let (path_input, fragment) = input - .split_once('#') - .map_or((input, None), |(path, fragment)| (path, Some(fragment))); - - if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) { - return Ok(MentionUri::Selection { - abs_path: Some(path_input.into()), - line_range: fragment, - column: None, - }); - } - - let path_with_position = PathWithPosition::parse_str(path_input); - let abs_path = path_with_position.path; - if let Some(row) = path_with_position.row { - let line = row - .checked_sub(1) - .context("Line numbers should be 1-based")?; - Ok(MentionUri::Selection { - abs_path: Some(abs_path), - line_range: line..=line, - column: path_with_position - .column - .map(|column| column.saturating_sub(1)), - }) - } else { - Ok(MentionUri::File { abs_path }) - } - }; - if is_absolute(input, path_style) && !input.contains("://") { return parse_absolute_path(input) .with_context(|| format!("Invalid absolute path mention URI: {input}")); @@ -168,7 +110,10 @@ impl MentionUri { }; let decoded = decode(trimmed).unwrap_or(Cow::Borrowed(trimmed)); let normalized: Cow = if path_style.is_windows() { - Cow::Owned(decoded.replace('/', "\\")) + match to_native_windows_path(&decoded) { + Some(native) => Cow::Owned(native), + None => decoded, + } } else { decoded }; @@ -337,6 +282,56 @@ impl MentionUri { } } + /// Parses a hyperlink target from agent-authored Markdown. + /// + /// Unlike [`MentionUri::parse`] — which stays strict so canonical mention + /// URIs round-trip verbatim — bare path targets are normalized first: + /// percent escapes are decoded (see [`decode_path_escapes`]) and + /// Windows-compatible spellings like `/C:/foo` or `/c/foo` become native + /// paths (see [`to_native_windows_path`]). + pub fn parse_hyperlink(input: &str, path_style: PathStyle) -> Result { + if let Some(target) = bare_path_target(input, path_style) { + return parse_hyperlink_path(target, path_style, DecodePercentEscapes::Yes) + .with_context(|| format!("Invalid hyperlink path target: {input}")); + } + Self::parse(input, path_style) + } + + /// Returns the literal (un-decoded) interpretation of a bare-path + /// hyperlink target, for files whose names literally contain an escape + /// sequence (e.g. `a%20b.rs`). Returns `None` when this wouldn't differ + /// from [`MentionUri::parse_hyperlink`], including for URLs, whose + /// escapes are unambiguous. + pub fn parse_hyperlink_literal(input: &str, path_style: PathStyle) -> Option { + let target = bare_path_target(input, path_style)?; + let (path_input, _) = split_path_fragment(target); + if !matches!(decode_path_escapes(path_input), Cow::Owned(_)) { + return None; + } + parse_hyperlink_path(target, path_style, DecodePercentEscapes::No).ok() + } + + /// The absolute path this mention refers to, if it refers to one. + pub fn abs_path(&self) -> Option<&Path> { + match self { + MentionUri::File { abs_path } + | MentionUri::Directory { abs_path } + | MentionUri::Symbol { abs_path, .. } => Some(abs_path), + MentionUri::Selection { abs_path, .. } => abs_path.as_deref(), + MentionUri::Skill { + skill_file_path, .. + } => Some(skill_file_path), + MentionUri::PastedImage { .. } + | MentionUri::Thread { .. } + | MentionUri::Rule { .. } + | MentionUri::Diagnostics { .. } + | MentionUri::Fetch { .. } + | MentionUri::TerminalSelection { .. } + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => None, + } + } + pub fn name(&self) -> String { match self { MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path @@ -599,6 +594,217 @@ impl fmt::Display for MentionLink<'_> { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DecodePercentEscapes { + Yes, + No, +} + +fn parse_line_range(fragment: &str) -> Result> { + let range = fragment.strip_prefix("L").unwrap_or(fragment); + + let (start, end) = if let Some((start, end)) = range.split_once(":") { + (start, end) + } else if let Some((start, end)) = range.split_once("-") { + // Also handle L10-20 or L10-L20 format + (start, end.strip_prefix("L").unwrap_or(end)) + } else { + // Single line number like L1872 - treat as a range of one line + (range, range) + }; + + let start_line = start + .parse::() + .context("Parsing line range start")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + let end_line = end + .parse::() + .context("Parsing line range end")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + + Ok(start_line..=end_line) +} + +/// Returns the mention target as a bare absolute path (not a URL), with the +/// backticks agents sometimes add stripped. +fn bare_path_target(input: &str, path_style: PathStyle) -> Option<&str> { + let input = input + .strip_prefix('`') + .and_then(|input| input.strip_suffix('`')) + .unwrap_or(input); + (is_absolute(input, path_style) && !input.contains("://")).then_some(input) +} + +fn split_path_fragment(input: &str) -> (&str, Option<&str>) { + input + .split_once('#') + .map_or((input, None), |(path, fragment)| (path, Some(fragment))) +} + +fn parse_absolute_path(input: &str) -> Result { + let (path_input, fragment) = split_path_fragment(input); + absolute_path_mention(path_input, fragment) +} + +/// Like [`parse_absolute_path`], but normalizes hyperlink spellings first. +fn parse_hyperlink_path( + input: &str, + path_style: PathStyle, + decode_escapes: DecodePercentEscapes, +) -> Result { + let (path_input, fragment) = split_path_fragment(input); + let path_input = normalize_path_mention(path_input, path_style, decode_escapes); + absolute_path_mention(&path_input, fragment) +} + +fn absolute_path_mention(path_input: &str, fragment: Option<&str>) -> Result { + if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) { + return Ok(MentionUri::Selection { + abs_path: Some(path_input.into()), + line_range: fragment, + column: None, + }); + } + + let path_with_position = PathWithPosition::parse_str(path_input); + let abs_path = path_with_position.path; + if let Some(row) = path_with_position.row { + let line = row + .checked_sub(1) + .context("Line numbers should be 1-based")?; + Ok(MentionUri::Selection { + abs_path: Some(abs_path), + line_range: line..=line, + column: path_with_position + .column + .map(|column| column.saturating_sub(1)), + }) + } else { + Ok(MentionUri::File { abs_path }) + } +} + +fn normalize_path_mention( + input: &str, + path_style: PathStyle, + decode_escapes: DecodePercentEscapes, +) -> Cow<'_, str> { + let decoded = match decode_escapes { + DecodePercentEscapes::Yes => decode_path_escapes(input), + DecodePercentEscapes::No => Cow::Borrowed(input), + }; + if !path_style.is_windows() { + return decoded; + } + match to_native_windows_path(&decoded) { + Some(native) => Cow::Owned(native), + None => decoded, + } +} + +/// Decodes percent escapes in a path, leaving separator escapes (`%2F`, +/// `%5C`) encoded so decoding can't change which directories the path +/// traverses. Invalid sequences and non-UTF-8 results leave the input +/// unchanged. Returns `Cow::Owned` iff decoding changed the input +/// (`parse_hyperlink_literal` relies on this). +fn decode_path_escapes(input: &str) -> Cow<'_, str> { + fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } + } + + if !input.contains('%') { + return Cow::Borrowed(input); + } + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && let Some(high) = bytes.get(index + 1).copied().and_then(hex_digit) + && let Some(low) = bytes.get(index + 2).copied().and_then(hex_digit) + { + let byte = (high << 4) | low; + if byte != b'/' && byte != b'\\' { + decoded.push(byte); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + if decoded == bytes { + return Cow::Borrowed(input); + } + match String::from_utf8(decoded) { + Ok(decoded) => Cow::Owned(decoded), + Err(_) => Cow::Borrowed(input), + } +} + +/// Converts Windows-compatible path spellings into a native Windows path, +/// normalizing separators to backslashes and drive letters to uppercase so +/// parsed paths compare equal to worktree paths. Returns `None` when the +/// input needs no changes. +fn to_native_windows_path(path: &str) -> Option { + fn join_drive(drive: char, rest: &str) -> String { + format!( + "{}:\\{}", + drive.to_ascii_uppercase(), + rest.replace('/', "\\") + ) + } + + if let Some(rest) = path.strip_prefix('/') { + // URL-style path with a leading slash before the drive: `/C:/foo`. + let mut chars = rest.chars(); + if let (Some(drive), Some(':'), Some('/' | '\\')) = + (chars.next(), chars.next(), chars.next()) + && drive.is_ascii_alphabetic() + { + return Some(join_drive(drive, chars.as_str())); + } + + // MSYS/Git Bash style: `/c/foo`. Lowercase-only, since that's what + // those shells emit and uppercase risks misreading real directories. + let mut chars = rest.chars(); + if let (Some(drive), Some('/' | '\\')) = (chars.next(), chars.next()) + && drive.is_ascii_lowercase() + { + return Some(join_drive(drive, chars.as_str())); + } + } + + // A native path with a drive prefix: uppercase the drive and normalize + // separators, e.g. `c:/foo` or `c:\foo`. + let mut chars = path.chars(); + if let (Some(drive), Some(':')) = (chars.next(), chars.next()) + && drive.is_ascii_alphabetic() + { + if drive.is_ascii_uppercase() && !path.contains('/') { + return None; + } + return Some(format!( + "{}:{}", + drive.to_ascii_uppercase(), + chars.as_str().replace('/', "\\") + )); + } + + if path.contains('/') { + return Some(path.replace('/', "\\")); + } + + None +} + fn default_include_errors() -> bool { true } @@ -727,6 +933,296 @@ mod tests { } } + #[test] + fn test_parse_file_uri_with_spaces() { + let parsed = + MentionUri::parse("file:///C:/path%20with%20space/file.rs", PathStyle::Windows) + .unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("C:\\path with space\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + assert_eq!( + MentionUri::File { + abs_path: PathBuf::from("C:\\path with space\\file.rs") + } + .to_uri() + .to_string(), + "file:///C:/path%20with%20space/file.rs" + ); + } + + #[test] + fn test_parse_windows_drive_path_with_leading_slash_and_line() { + let parsed = MentionUri::parse_hyperlink( + "/C:/Projects/Example Workspace/Cargo.toml:2", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\Cargo.toml") + ); + assert_eq!(line_range, 1..=1); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_path_with_percent_escaped_spaces_and_line() { + let parsed = MentionUri::parse_hyperlink( + "C:\\Projects\\Example%20Workspace\\path\\to\\filename.ext:42", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\path\\to\\filename.ext") + ); + assert_eq!(line_range, 41..=41); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_compat_path_with_spaces() { + let parsed = MentionUri::parse_hyperlink( + "/c/Projects/Example Workspace/AGENTS.md", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\AGENTS.md") + ); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_drive_path_with_leading_slash_and_fragment_line() { + let parsed = + MentionUri::parse_hyperlink("/C:/Projects/Cargo.toml#L4", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!(abs_path, PathBuf::from("C:\\Projects\\Cargo.toml")); + assert_eq!(line_range, 3..=3); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_windows_drive_path_with_leading_slash_round_trips() { + let parsed = MentionUri::parse_hyperlink("/C:/dir/file.rs", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\file.rs") + } + ); + let uri = parsed.to_uri().to_string(); + assert_eq!(uri, "file:///C:/dir/file.rs"); + assert_eq!(MentionUri::parse(&uri, PathStyle::Windows).unwrap(), parsed); + } + + #[test] + fn test_parse_windows_unc_path() { + let parsed = + MentionUri::parse_hyperlink("//server/share/dir/file.rs", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("\\\\server\\share\\dir\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_drive_letters_are_uppercased() { + for input in [ + "file:///c:/foo/bar.rs", + "/c:/foo/bar.rs", + "/c/foo/bar.rs", + "c:\\foo\\bar.rs", + "c:/foo/bar.rs", + ] { + let parsed = MentionUri::parse_hyperlink(input, PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\foo\\bar.rs") + }, + "input: {input}" + ); + } + } + + #[test] + fn test_msys_style_paths_require_lowercase_drive() { + // Uppercase `/C/foo` is more likely a real directory than a drive. + let parsed = MentionUri::parse_hyperlink("/C/Users/readme.md", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("\\C\\Users\\readme.md")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_posix_paths_are_not_rewritten_as_windows_drives() { + let parsed = MentionUri::parse_hyperlink("/c/Projects/AGENTS.md", PathStyle::Unix).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("/c/Projects/AGENTS.md")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_hyperlink_percent_escapes_are_decoded() { + let parsed = MentionUri::parse_hyperlink("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a b.rs") + } + ); + + // Invalid escape sequences pass through unchanged. + let parsed = + MentionUri::parse_hyperlink("C:\\dir\\100%_done.txt", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\100%_done.txt") + } + ); + + // Separator escapes stay encoded (no introduced path traversal). + let parsed = MentionUri::parse_hyperlink("/tmp/a%2Fb.rs", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%2Fb.rs") + } + ); + let parsed = MentionUri::parse_hyperlink("/tmp/..%2F..%2Fsecret", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/..%2F..%2Fsecret") + } + ); + } + + #[test] + fn test_parse_keeps_bare_path_targets_verbatim() { + let parsed = MentionUri::parse("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%20b.rs") + } + ); + + let parsed = MentionUri::parse("/c/Projects/AGENTS.md", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/c/Projects/AGENTS.md") + } + ); + } + + #[test] + fn test_parse_hyperlink_literal_keeps_percent_escapes() { + let literal = + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); + assert_eq!( + literal, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%20b.rs") + } + ); + + // Line suffixes still parse. + let literal = + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs:42", PathStyle::Unix).unwrap(); + assert_eq!( + literal, + MentionUri::Selection { + abs_path: Some(PathBuf::from("/tmp/a%20b.rs")), + line_range: 41..=41, + column: None, + } + ); + + // Windows normalization still applies. + let literal = + MentionUri::parse_hyperlink_literal("/C:/dir/a%20b.rs", PathStyle::Windows).unwrap(); + assert_eq!( + literal, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\a%20b.rs") + } + ); + } + + #[test] + fn test_parse_hyperlink_literal_returns_none_when_unambiguous() { + // No percent escapes: identical to `parse_hyperlink`. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/a b.rs", PathStyle::Unix), + None + ); + // Invalid escape sequences are also left alone by `parse_hyperlink`. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/100%_done.txt", PathStyle::Unix), + None + ); + // Separator escapes are never decoded, so they're not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/a%2Fb.rs", PathStyle::Unix), + None + ); + // URLs are spec-encoded, not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("file:///tmp/a%20b.rs", PathStyle::Unix), + None + ); + // Relative paths are not bare-path mentions. + assert_eq!( + MentionUri::parse_hyperlink_literal("tmp/a%20b.rs", PathStyle::Unix), + None + ); + } + #[test] fn test_to_directory_uri_without_slash() { let uri = MentionUri::Directory { @@ -978,7 +1474,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_row() { let file_path = "/path/to/file.rs:42"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -996,7 +1492,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_row_and_column() { let file_path = "/path/to/file.rs:42:5"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -1008,7 +1504,7 @@ mod tests { assert_eq!(line_range.end(), &41); assert_eq!(column, &Some(4)); - let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Posix) + let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Unix) .expect("selection URI with column should parse"); assert_eq!(parsed_again, parsed.clone()); } @@ -1019,7 +1515,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_fragment_line() { let file_path = "/path/to/file.rs#L42"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -1091,7 +1587,7 @@ mod tests { #[test] fn test_parse_backticked_absolute_file_path() { let file_path = "`/path/to/file.rs`"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::File { abs_path } => { assert_eq!(abs_path, Path::new("/path/to/file.rs")); @@ -1103,7 +1599,7 @@ mod tests { #[test] fn test_parse_backticked_absolute_file_path_with_fragment_line() { let file_path = "`/path/to/file.rs#L42`"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index b39d1e52a4e2ac..cce475268e68aa 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -46,19 +46,23 @@ pub struct SandboxWrap { pub extra_write_paths: Vec, /// Outbound network access explicitly approved for this command. pub network: SandboxNetworkAccess, - /// The project's `.git` directories (worktree `.git`, linked-worktree common - /// dirs, discovered repos). Protected by default; made writable when - /// `allow_git_access` is set. Computed by the agent because locating them - /// needs Git knowledge the sandbox layer can't derive itself. - pub git_dirs: Vec, - /// Whether the user approved access to the protected `.git` directories. - pub allow_git_access: bool, - /// Allow unrestricted filesystem writes (ignores all writable paths). + /// Additional paths that should remain readable but not writable, even when + /// they fall under writable paths. + pub protected_paths: Vec, + /// Allow unrestricted filesystem writes except for protected paths (ignores + /// ordinary writable paths). pub allow_fs_write: bool, /// Whether the project (and therefore this terminal) is local. The /// enforcing proxy binds a loopback port on this host, so it can only /// confine local commands; a remote terminal can't reach it. pub is_local: bool, + /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to + /// provision inside WSL as the sandbox helper (version `latest` for dev + /// builds). Resolved by the agent (which can read the running app's release + /// info) and forwarded to the sandbox. `None` on other platforms, or when + /// the release can't be determined, in which case the WSL backend falls back + /// to running bwrap without in-sandbox bind validation. + pub wsl_zed_release: Option<(String, String)>, } #[derive(Clone, Debug, Default)] @@ -125,6 +129,31 @@ impl LinuxWslSandboxError { LinuxWslSandboxError::Other(message) => message.clone(), } } + + /// The slug of the sandboxing docs section that best explains how to resolve + /// this failure, for deep-linking from the UI. Pair with + /// `client::zed_urls::sandboxing_docs`. + pub fn docs_section(&self) -> &'static str { + match self { + // Both "no bwrap" and "only a setuid-root bwrap" are resolved by + // installing a non-setuid Bubblewrap. + LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => { + "installing-bubblewrap" + } + // A failed probe on Linux is almost always disabled unprivileged + // user namespaces, which the Ubuntu-specific section covers. + LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu", + // Catch-all (includes WSL/Windows messages): point at the platform + // overview for the current OS. + LinuxWslSandboxError::Other(_) => { + if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + } + } + } + } } impl SandboxWrap { @@ -137,25 +166,47 @@ impl SandboxWrap { /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on /// Linux, so call it off the main thread. On platforms whose sandbox can't /// fail to set up this way it always returns `Ok`. - pub fn can_create_sandbox( - &self, - cwd: Option<&std::path::Path>, - ) -> Result<(), LinuxWslSandboxError> { - sandbox::Sandbox::can_create(&self.to_policy(), cwd).map_err(LinuxWslSandboxError::from) + pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> { + sandbox::Sandbox::can_create(&self.to_policy()).map_err(LinuxWslSandboxError::from) } /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`]. + /// + /// This is the enforcement-policy construction point, so it **captures** each + /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical + /// path) rather than passing a re-resolvable path. A location that can't be + /// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed. + /// + /// This function has **no filesystem side effects**: it never creates paths. + /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe + /// and by real sandbox construction, and must behave identically. On Linux a + /// writable grant that doesn't exist yet simply can't be captured (bwrap + /// can't bind a missing path), so it's dropped here — the sanctioned way to + /// get a grant to a new directory is the `create_directory` tool, which + /// creates it (pinning the inode) before the grant is recorded. On macOS a + /// missing leaf still canonicalizes, so such grants are captured directly. fn to_policy(&self) -> sandbox::SandboxPolicy { + let protected_paths = self + .protected_paths + .iter() + .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok()) + .collect(); let fs = if self.allow_fs_write { - sandbox::SandboxFsPolicy::Unrestricted + sandbox::SandboxFsPolicy::Unrestricted { protected_paths } } else { + let writable_paths = self + .writable_paths + .iter() + .chain(self.extra_write_paths.iter()) + // Capture only — never create anything here (see the doc comment): + // materializing an approved-but-missing grant is deferred to + // `Sandbox::new` so it can never happen during the `can_create` + // probe, before the user has approved the grant. + .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok()) + .collect(); sandbox::SandboxFsPolicy::Restricted { - writable_paths: self - .writable_paths - .iter() - .cloned() - .chain(self.extra_write_paths.iter().cloned()) - .collect(), + writable_paths, + protected_paths, } }; let network = match &self.network { @@ -169,13 +220,7 @@ impl SandboxWrap { .collect(), }, }; - let git_dirs = self.git_dirs.clone(); - let git = if self.allow_git_access { - sandbox::GitSandboxPolicy::Allowed { git_dirs } - } else { - sandbox::GitSandboxPolicy::Denied { git_dirs } - }; - sandbox::SandboxPolicy { fs, network, git } + sandbox::SandboxPolicy { fs, network } } } @@ -238,6 +283,12 @@ pub(crate) async fn prepare_sandbox_wrap( let mut sandbox = sandbox::Sandbox::new(sandbox_wrap.to_policy()).map_err(anyhow::Error::new)?; + // Windows/WSL only: tell the sandbox which Linux `zed` to provision inside + // WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere. + #[cfg(target_os = "windows")] + if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() { + sandbox.set_wsl_zed_release(channel, version); + } let command = sandbox::CommandAndArgs { program, args, diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index f8b15f621e9d6b..6e2de669dedfc9 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -758,11 +758,10 @@ impl ActionLog { .read(cx) .entry_id(cx) .and_then(|entry_id| { - self.project.update(cx, |project, cx| { - project.delete_entry(entry_id, false, cx) - }) + self.project + .update(cx, |project, cx| project.delete_entry(entry_id, cx)) }) - .unwrap_or_else(|| Task::ready(Ok(None))); + .unwrap_or_else(|| Task::ready(Ok(()))); cx.background_spawn(async move { task.await?; @@ -1048,23 +1047,12 @@ pub struct DiffStats { } impl DiffStats { - pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { - let mut stats = DiffStats::default(); - let diff_snapshot = diff.snapshot(cx); - let buffer_snapshot = buffer.snapshot(); - let base_text = diff_snapshot.base_text(); - - for hunk in diff_snapshot.hunks(&buffer_snapshot) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - stats.lines_added += added_rows; - - let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; - let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; - let removed_rows = base_end.saturating_sub(base_start); - stats.lines_removed += removed_rows; + pub fn single_file(diff: &BufferDiff) -> Self { + let (lines_added, lines_removed) = diff.changed_row_counts(); + DiffStats { + lines_added, + lines_removed, } - - stats } pub fn all_files( @@ -1072,8 +1060,8 @@ impl DiffStats { cx: &App, ) -> Self { let mut total = DiffStats::default(); - for (buffer, diff) in changed_buffers { - let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + for (_, diff) in changed_buffers { + let stats = DiffStats::single_file(diff.read(cx)); total.lines_added += stats.lines_added; total.lines_removed += stats.lines_removed; } @@ -1831,14 +1819,14 @@ mod tests { 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) + project.delete_file(file1_path.clone(), cx) }) .unwrap() .await .unwrap(); project .update(cx, |project, cx| { - project.delete_file(file2_path.clone(), false, cx) + project.delete_file(file2_path.clone(), cx) }) .unwrap() .await @@ -2143,9 +2131,7 @@ mod tests { 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) - }) + .update(cx, |project, cx| project.delete_file(file_path.clone(), cx)) .unwrap() .await .unwrap(); @@ -3156,7 +3142,7 @@ mod tests { child_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); }); project - .update(cx, |project, cx| project.delete_file(file_path, false, cx)) + .update(cx, |project, cx| project.delete_file(file_path, cx)) .unwrap() .await .unwrap(); diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 4ca66790b0eb3d..70871044e6ed1a 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -671,6 +671,7 @@ impl Render for ActivityIndicator { } }) .label_size(LabelSize::Small) + .tab_index(0isize) .map(|this| match content.icon { ActivityIcon::LoadingSpinner => this.loading(true), ActivityIcon::Icon(icon_name) => this.start_icon( diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8420cbede15273..30eb959298ea7e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -79,6 +79,11 @@ web_search.workspace = true zed_env_vars.workspace = true zstd.workspace = true +# Used only on Windows to resolve the running release channel/version so the WSL +# sandbox helper can fetch a matching Linux `zed`. +[target.'cfg(target_os = "windows")'.dependencies] +release_channel.workspace = true + [dev-dependencies] assets.workspace = true async-io.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 75e52207f27f6e..f96174baac1586 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -374,7 +374,10 @@ impl LanguageModels { } } - cx.update(language_models::update_environment_fallback_model); + cx.update(|cx| { + LanguageModelRegistry::global(cx) + .update(cx, |registry, cx| registry.refresh_fallback_model(cx)) + }); }) } } @@ -443,18 +446,22 @@ impl gpui::EventEmitter for NativeAgent {} static RULES_FILE_REL_PATHS: LazyLock>> = LazyLock::new(|| { RULES_FILE_NAMES .iter() - .filter_map(|name| RelPath::unix(name).ok().map(|path| path.into_arc())) + .filter_map(|name| { + RelPath::from_unix_str(name) + .ok() + .map(|path| path.into_arc()) + }) .collect() }); static AGENTS_PREFIX: LazyLock>> = LazyLock::new(|| { - RelPath::unix(AGENTS_DIR_NAME) + RelPath::from_unix_str(AGENTS_DIR_NAME) .ok() .map(|path| path.into_arc()) }); static SKILLS_PREFIX: LazyLock>> = LazyLock::new(|| { - RelPath::unix(project_skills_relative_path()) + RelPath::from_unix_str(project_skills_relative_path()) .ok() .map(|path| path.into_arc()) }); @@ -489,7 +496,7 @@ async fn expand_project_skills_directories( worktree: &Entity, cx: &mut AsyncApp, ) -> Result<()> { - let agents_dir = RelPath::unix(AGENTS_DIR_NAME)?; + let agents_dir = RelPath::from_unix_str(AGENTS_DIR_NAME)?; let Some(skills_prefix) = SKILLS_PREFIX.as_ref() else { return Ok(()); }; @@ -515,7 +522,7 @@ fn project_skill_files_from_worktree(worktree: &Worktree) -> Vec Vec Vec Vec { let mut git_dirs = Vec::new(); @@ -84,9 +77,9 @@ pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec { /// UI renders and enforcement builds from. "No sandbox" is its own variant /// rather than a maximally-permissive [`SandboxPolicy`] so that a wide-open but /// real sandbox (e.g. `allow_fs_write_all` + `allow_all_hosts`) stays -/// distinguishable from running with no sandbox at all — the two grant the same -/// filesystem/network reach but only the latter means the command runs with -/// ambient permissions. +/// distinguishable from running with no sandbox at all. The sandbox still +/// enforces invariants such as read-only Git metadata, while unsandboxed commands +/// run with ambient permissions. #[derive(Clone, Debug, Eq, PartialEq)] pub enum ThreadSandbox { /// No OS sandbox is applied; commands run with ambient permissions. @@ -118,20 +111,20 @@ impl ThreadSandbox { matches!(self, ThreadSandbox::Unsandboxed) } - /// Attach the project's Git policy to a sandboxed layer. The settings/grants - /// don't know the project's `.git` locations, so the caller computes them - /// (via [`sandbox_git_dirs`]) and passes whether this layer grants Git - /// access. A no-op for the `Unsandboxed` variant. - pub fn with_git(self, allowed: bool, git_dirs: Vec) -> ThreadSandbox { + /// Attach the project's protected paths to a sandboxed layer. The settings + /// and grants don't know the project's `.git` locations, so the caller + /// computes them via [`sandbox_git_dirs`]. A no-op for `Unsandboxed`. + pub fn with_protected_paths(self, protected_paths: Vec) -> ThreadSandbox { match self { ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed, ThreadSandbox::Sandboxed(policy) => { - let git = if allowed { - GitSandboxPolicy::Allowed { git_dirs } - } else { - GitSandboxPolicy::Denied { git_dirs } - }; - ThreadSandbox::Sandboxed(policy.with_git(git)) + // Capture each protected location (pinning its inode / canonical + // path). A location that can't be captured is dropped — fail-closed. + let protected_paths = protected_paths + .into_iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(); + ThreadSandbox::Sandboxed(policy.with_protected_paths(protected_paths)) } } } @@ -155,10 +148,17 @@ pub fn settings_thread_sandbox(persistent: &SandboxPermissions) -> ThreadSandbox /// from [`ThreadSandboxGrants::to_policy`]. pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy { let fs = if persistent.allow_fs_write_all { - SandboxFsPolicy::Unrestricted + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } } else { SandboxFsPolicy::Restricted { - writable_paths: persistent.write_paths.clone(), + writable_paths: persistent + .write_paths + .iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(), + protected_paths: Vec::new(), } }; let network = if persistent.allow_all_hosts { @@ -170,19 +170,7 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy allowed_domains: persistent.network_hosts.clone(), } }; - // The persistent settings don't know the project's `.git` locations; the UI - // layer attaches the real Git policy via `SandboxPolicy::with_git`. - SandboxPolicy { - fs, - network, - git: GitSandboxPolicy::default(), - } -} - -/// Whether agent-run terminal commands should be wrapped in an OS-level -/// sandbox for this process. See module docs for the policy. -pub(crate) fn sandboxing_enabled(cx: &App) -> bool { - cx.has_flag::() + SandboxPolicy { fs, network } } /// Whether the sandboxed terminal can be exposed for this project. @@ -196,20 +184,19 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool { /// prompt in place, since the model is still operating in the sandbox model and /// only escaping individual commands (tracked in `ThreadSandboxGrants`). pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool { - sandboxing_available_for_project(project, cx) + sandboxing_available_for_project(project) && !AgentSettings::get_global(cx) .sandbox_permissions .allow_unsandboxed } -/// Whether sandboxing is *applicable* for this project at all — the feature is -/// enabled, the project is local, and the platform has a sandbox integration — -/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to -/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from -/// "sandboxing is available but turned off in settings" (show it, struck out). -pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool { - sandboxing_enabled(cx) - && project.is_local() +/// Whether sandboxing is *applicable* for this project at all — the project is +/// local and the platform has a sandbox integration — independent of the +/// persistent `allow_unsandboxed` setting. Used by the UI to distinguish +/// "sandboxing isn't relevant here" (don't show the indicator) from "sandboxing +/// is available but turned off in settings" (show it, struck out). +pub(crate) fn sandboxing_available_for_project(project: &Project) -> bool { + project.is_local() && cfg!(any( target_os = "macos", target_os = "linux", @@ -262,8 +249,7 @@ impl NetworkRequest { pub(crate) struct SandboxRequest { /// Outbound network access requested for this command. pub network: NetworkRequest, - /// Allow access to protected Git metadata paths. - pub allow_git_access: bool, + /// Allow unrestricted filesystem writes (the broad escape hatch). pub allow_fs_write_all: bool, /// Run the command fully outside the sandbox. @@ -278,7 +264,6 @@ impl SandboxRequest { /// scope, and therefore needs user approval. pub fn needs_escalation(&self) -> bool { self.network.is_requested() - || self.allow_git_access || self.allow_fs_write_all || self.unsandboxed || !self.write_paths.is_empty() @@ -299,7 +284,6 @@ pub(crate) struct ThreadSandboxGrants { /// Host patterns granted network access for the thread. Each covers its /// whole subdomain space; redundant entries are pruned on insert. network_hosts: Vec, - allow_git_access: bool, allow_fs_write_all: bool, unsandboxed: bool, /// Whether the user approved running commands *without* a sandbox for the @@ -340,14 +324,13 @@ impl ThreadSandboxGrants { if !self.network_covered(&request.network, persistent) { return false; } - if request.allow_git_access && !(self.allow_git_access || persistent.allow_git_access) { - return false; - } + if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all) { return false; } - // A full-access write grant covers any concrete write request. + // A full-access write grant covers any concrete write request at the + // authorization layer; protected paths are enforced by the sandbox. if self.allow_fs_write_all || persistent.allow_fs_write_all { return true; } @@ -399,12 +382,6 @@ impl ThreadSandboxGrants { self.unsandboxed } - /// Whether the user approved access to protected Git directories for the - /// rest of the thread. - pub fn git_access_granted(&self) -> bool { - self.allow_git_access - } - /// Record that the user approved running commands unsandboxed for the rest /// of the thread when the sandbox can't be created. Only the Bubblewrap /// sandboxes (Linux directly, Windows via WSL) can fail to create a @@ -434,10 +411,17 @@ impl ThreadSandboxGrants { /// from [`settings_sandbox_policy`]. pub fn to_policy(&self) -> SandboxPolicy { let fs = if self.allow_fs_write_all { - SandboxFsPolicy::Unrestricted + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } } else { SandboxFsPolicy::Restricted { - writable_paths: self.write_paths.clone(), + writable_paths: self + .write_paths + .iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(), + protected_paths: Vec::new(), } }; let network = if self.network_any_host { @@ -453,13 +437,7 @@ impl ThreadSandboxGrants { .collect(), } }; - // Grants don't carry the project's `.git` locations; the UI layer - // attaches the real Git policy via `SandboxPolicy::with_git`. - SandboxPolicy { - fs, - network, - git: GitSandboxPolicy::default(), - } + SandboxPolicy { fs, network } } /// Serialize these grants for persistence in the thread's database row. @@ -474,7 +452,6 @@ impl ThreadSandboxGrants { .map(|host| host.to_string()) .collect(), network_any_host: self.network_any_host, - allow_git_access: self.allow_git_access, allow_fs_write_all: self.allow_fs_write_all, unsandboxed: self.unsandboxed, sandbox_fallback: self.sandbox_fallback, @@ -497,7 +474,6 @@ impl ThreadSandboxGrants { Self { network_any_host: db.network_any_host, network_hosts, - allow_git_access: db.allow_git_access, allow_fs_write_all: db.allow_fs_write_all, unsandboxed: db.unsandboxed, sandbox_fallback: db.sandbox_fallback, @@ -517,7 +493,6 @@ impl ThreadSandboxGrants { } } } - self.allow_git_access |= request.allow_git_access; self.allow_fs_write_all |= request.allow_fs_write_all; self.unsandboxed |= request.unsandboxed; for path in &request.write_paths { @@ -569,9 +544,6 @@ impl ThreadSandboxGrants { } SandboxRequest { network, - allow_git_access: persistent.allow_git_access - || self.allow_git_access - || request.allow_git_access, allow_fs_write_all: persistent.allow_fs_write_all || self.allow_fs_write_all || request.allow_fs_write_all, @@ -613,6 +585,7 @@ pub(crate) fn insert_host_pattern(set: &mut Vec, pattern: HostPatte #[cfg(test)] mod tests { use super::*; + use std::path::Path; fn hosts(list: &[&str]) -> NetworkRequest { NetworkRequest::Hosts( @@ -625,7 +598,6 @@ mod tests { fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest { SandboxRequest { network, - allow_git_access: false, allow_fs_write_all: all, unsandboxed: false, write_paths: paths.iter().map(PathBuf::from).collect(), @@ -635,7 +607,6 @@ mod tests { fn unsandboxed_request() -> SandboxRequest { SandboxRequest { network: NetworkRequest::None, - allow_git_access: false, allow_fs_write_all: false, unsandboxed: true, write_paths: Vec::new(), @@ -644,9 +615,20 @@ mod tests { #[test] fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() { - let policy = |paths: &[&str], hosts: &[&str]| SandboxPolicy { + // Writable paths are captured as real `HostFilesystemLocation`s (which + // open an fd and key on the inode), so the test uses real directories. + let dir_a = tempfile::tempdir().expect("create temp dir a"); + let dir_b = tempfile::tempdir().expect("create temp dir b"); + let path_a = dir_a.path(); + let path_b = dir_b.path(); + + let policy = |paths: &[&Path], hosts: &[&str]| SandboxPolicy { fs: SandboxFsPolicy::Restricted { - writable_paths: paths.iter().map(PathBuf::from).collect(), + writable_paths: paths + .iter() + .map(|p| HostFilesystemLocation::new(p).expect("capture temp dir")) + .collect(), + protected_paths: Vec::new(), }, network: if hosts.is_empty() { SandboxNetPolicy::Blocked @@ -655,26 +637,25 @@ mod tests { allowed_domains: hosts.iter().map(|h| h.to_string()).collect(), } }, - git: GitSandboxPolicy::default(), }; // Unsandboxed on either side wins — the agent runs with ambient access. assert!( ThreadSandbox::Unsandboxed - .merge(ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"]))) + .merge(ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))) .is_unsandboxed() ); assert!( - ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) .merge(ThreadSandbox::Unsandboxed) .is_unsandboxed() ); // Two sandboxed layers union their scopes. assert_eq!( - ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) - .merge(ThreadSandbox::Sandboxed(policy(&["/b"], &["b.com"]))), - ThreadSandbox::Sandboxed(policy(&["/a", "/b"], &["a.com", "b.com"])) + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) + .merge(ThreadSandbox::Sandboxed(policy(&[path_b], &["b.com"]))), + ThreadSandbox::Sandboxed(policy(&[path_a, path_b], &["a.com", "b.com"])) ); } @@ -743,13 +724,20 @@ mod tests { fn thread_grants_to_policy_maps_paths_and_domains() { use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + // `to_policy` captures real `HostFilesystemLocation`s, so use a real dir. + let build_dir = tempfile::tempdir().expect("create temp build dir"); + let build_path = build_dir.path().to_str().expect("utf-8 temp path"); + let mut grants = ThreadSandboxGrants::default(); - grants.record(&request(hosts(&["github.com"]), false, &["/tmp/build"])); + grants.record(&request(hosts(&["github.com"]), false, &[build_path])); let policy = grants.to_policy(); assert_eq!( policy.fs, SandboxFsPolicy::Restricted { - writable_paths: vec![PathBuf::from("/tmp/build")] + writable_paths: vec![ + HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir") + ], + protected_paths: Vec::new(), } ); assert_eq!( @@ -764,7 +752,8 @@ mod tests { assert_eq!( empty.fs, SandboxFsPolicy::Restricted { - writable_paths: Vec::new() + writable_paths: Vec::new(), + protected_paths: Vec::new(), } ); assert_eq!(empty.network, SandboxNetPolicy::Blocked); @@ -773,7 +762,12 @@ mod tests { let mut broad = ThreadSandboxGrants::default(); broad.record(&request(NetworkRequest::AnyHost, true, &[])); let policy = broad.to_policy(); - assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted); + assert_eq!( + policy.fs, + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } + ); assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); } @@ -781,8 +775,10 @@ mod tests { fn settings_policy_maps_persistent_permissions() { use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + // `settings_sandbox_policy` captures real `HostFilesystemLocation`s. + let log_dir = tempfile::tempdir().expect("create temp log dir"); let persistent = SandboxPermissions { - write_paths: vec![PathBuf::from("/var/log")], + write_paths: vec![log_dir.path().to_path_buf()], network_hosts: vec!["*.npmjs.org".to_string()], ..Default::default() }; @@ -790,7 +786,10 @@ mod tests { assert_eq!( policy.fs, SandboxFsPolicy::Restricted { - writable_paths: vec![PathBuf::from("/var/log")] + writable_paths: vec![ + HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir") + ], + protected_paths: Vec::new(), } ); assert_eq!( @@ -806,7 +805,12 @@ mod tests { ..Default::default() }; let policy = settings_sandbox_policy(&unrestricted); - assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted); + assert_eq!( + policy.fs, + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } + ); assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); } @@ -992,33 +996,6 @@ mod tests { assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); } - #[test] - fn git_access_grant_tracked_independently() { - let mut git_request = request(NetworkRequest::None, false, &[]); - git_request.allow_git_access = true; - - let mut grants = ThreadSandboxGrants::default(); - assert!(!covers(&grants, &git_request)); - - grants.record(&git_request); - assert!(covers(&grants, &git_request)); - assert!(!covers( - &grants, - &request(NetworkRequest::AnyHost, false, &[]) - )); - assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); - } - - #[test] - fn unrestricted_writes_do_not_cover_git_access() { - let mut grants = ThreadSandboxGrants::default(); - grants.record(&request(NetworkRequest::None, true, &[])); - - let mut git_request = request(NetworkRequest::None, false, &[]); - git_request.allow_git_access = true; - assert!(!covers(&grants, &git_request)); - } - #[test] fn persistent_grants_combine_with_thread_grants() { let mut grants = ThreadSandboxGrants::default(); @@ -1151,7 +1128,6 @@ mod tests { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_all_hosts: true, - allow_git_access: true, write_paths: vec![PathBuf::from("/tmp/always")], ..Default::default() }; @@ -1159,7 +1135,6 @@ mod tests { let effective = grants .effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent); assert_eq!(effective.network, NetworkRequest::AnyHost); - assert!(effective.allow_git_access); assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]); } diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index 582c12f4fe31e8..51952c07dc11e4 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -122,7 +122,7 @@ mod tests { root_name: "my-project".to_string(), abs_path: std::path::Path::new("/tmp/my-project").into(), rules_file: Some(RulesFileContext { - path_in_worktree: RelPath::unix("AGENTS.md").unwrap().into(), + path_in_worktree: RelPath::from_unix_str("AGENTS.md").unwrap().into(), text: "project-specific guidance".to_string(), project_entry_id: 1, }), @@ -209,13 +209,12 @@ mod tests { assert!(rendered.contains("allow_hosts")); assert!(rendered.contains("allow_all_hosts: true")); assert!(rendered.contains("fs_write_paths")); - assert!(rendered.contains("allow_git_access: true")); assert!(rendered.contains("allow_fs_write_all: true")); assert!(rendered.contains("unsandboxed: true")); - assert!(rendered.contains("file contents under `.git` directories")); - assert!( - rendered.contains("worktree metadata that may live outside the project directories") - ); + assert!(rendered.contains("`.git` directories remain protected")); + assert!(rendered.contains("Git metadata writes are never grantable inside the sandbox")); + assert!(rendered.contains("request `unsandboxed: true` with a reason")); + assert!(rendered.contains("git --no-optional-locks status")); assert!(rendered.contains("for the rest of the thread")); } @@ -249,6 +248,37 @@ mod tests { assert!(rendered.contains("`/tmp/alpha`")); } + #[test] + fn test_system_prompt_windows_sandbox_section_rejects_host_specific_network() { + use prompt_store::{ProjectContext, WorktreeContext}; + + let worktrees = vec![WorktreeContext { + root_name: "alpha".to_string(), + abs_path: std::path::Path::new("C:/Users/me/project").into(), + rules_file: None, + }]; + let project = ProjectContext::new(worktrees); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: true, + is_linux: false, + is_windows: true, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(rendered.contains("commands run inside WSL under Bubblewrap")); + assert!(rendered.contains("Protected Git metadata remains read-only")); + assert!(rendered.contains("do not use this on Windows")); + assert!(rendered.contains("such requests are rejected")); + assert!(rendered.contains("allow_all_hosts: true")); + assert!(rendered.contains("git --no-optional-locks status")); + } + #[test] fn test_system_prompt_sandbox_section_handles_zero_worktrees() { let project = prompt_store::ProjectContext::default(); diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index c615d0958fc219..233a0e45215f6c 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -158,13 +158,13 @@ The current project contains the following root directories: The `terminal` tool runs commands inside a sandbox with these permissions: -- Reads: any path on the filesystem is readable, except that file contents under `.git` directories are blocked by default (their metadata stays visible). See `allow_git_access` below. +- Reads: any path on the filesystem is readable, including Git metadata. {{#if is_linux}} - Writes: `/tmp` is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls: {{#each worktrees}} - `{{abs_path}}` {{/each}} - `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{else}} {{#if is_windows}} - Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt//...` filesystem view. @@ -172,13 +172,13 @@ The `terminal` tool runs commands inside a sandbox with these permissions: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}} + Protected Git metadata remains read-only. Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}} {{else}} - Writes: a per-thread temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable and persists across `terminal` calls in this thread{{#if worktrees}}, along with these project directories: {{#each worktrees}} - `{{abs_path}}` {{/each}} - `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{/if}} {{/if}} - Network: outbound network access is blocked. @@ -189,7 +189,7 @@ The sandbox can only allow or block outbound network access as a whole — it ca You can request elevated permissions on individual `terminal` calls: - `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access. -- `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit. +- `allow_hosts: ["github.com", ...]` — do not use this on Windows. Host-specific network grants cannot be enforced, and such requests are rejected; use `allow_all_hosts: true` when the command genuinely needs network access. {{else}} Host-scoped network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). When access is scoped to specific hosts, tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach them, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing. @@ -198,10 +198,11 @@ You can request elevated permissions on individual `terminal` calls: - `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs. - `allow_all_hosts: true` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front. {{/if}} -- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. -- `allow_git_access: true` — lift the default block on Git metadata, allowing reads and writes of file contents under `.git` directories (including worktree metadata that may live outside the project directories). Any command that touches `.git` needs this, including ones that invoke Git only incidentally (e.g. build scripts calling `git describe`/`git rev-parse`, or commit hooks). -- `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front. -- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice. +- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Each path must be an existing directory. To write into a directory that doesn't exist yet, first create it with the `create_directory` tool (which creates it and grants write access to exactly that directory) rather than requesting write access to a broad existing parent. Git metadata paths cannot be requested and will never be made writable while sandboxed. +- `allow_fs_write_all: true` — allow unrestricted filesystem writes except protected Git metadata. Only use this when the specific paths can't be enumerated up front. +- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice, including when a command must write Git metadata. + +Git metadata writes are never grantable inside the sandbox. If a command needs to update `.git`, linked worktree metadata, refs, the index, hooks, local Git config, or other Git metadata, request `unsandboxed: true` with a reason. For read-only Git operations, prefer flags that avoid optional metadata writes where possible, such as `git --no-optional-locks status` instead of `git status`. The user will be prompted to approve before the command runs, and can grant a sandbox request for that command, for the rest of the thread, or always. Once a host or write path is granted for the thread or always, later commands in this thread reaching that host or writing under that path won't prompt again. diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 11f542ee8e6cf0..22d05feb8802c1 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -689,7 +689,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -840,17 +840,25 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) { assert_eq!(last_tool_use.name.as_ref(), "word_list"); if tool_call.status == acp::ToolCallStatus::Pending { if !last_tool_use.is_input_complete - && last_tool_use.input.get("g").is_none() + && last_tool_use + .input + .as_json() + .and_then(|input| input.get("g")) + .is_none() { saw_partial_tool_use = true; } } else { last_tool_use .input + .as_json() + .expect("tool input should be JSON") .get("a") .expect("'a' has streamed because input is now complete"); last_tool_use .input + .as_json() + .expect("tool input should be JSON") .get("g") .expect("'g' has streamed because input is now complete"); } @@ -884,7 +892,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -894,7 +902,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_2".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -951,7 +959,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_3".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -990,7 +998,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_4".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1029,7 +1037,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: "nonexistent_tool".into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1533,7 +1541,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }, @@ -1577,7 +1585,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_2".into(), name: "test_server_echo".into(), raw_input: json!({"text": "mcp"}).to_string(), - input: json!({"text": "mcp"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "mcp"})), is_input_complete: true, thought_signature: None, }, @@ -1587,7 +1595,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_3".into(), name: "echo".into(), raw_input: json!({"text": "native"}).to_string(), - input: json!({"text": "native"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "native"})), is_input_complete: true, thought_signature: None, }, @@ -1633,6 +1641,97 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { events.collect::>().await; } +#[gpui::test] +async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + context_server_store, + fs, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "tool_permissions": { "default": "allow" }, + "profiles": { + "test": { + "name": "Test Profile", + "enable_all_context_servers": true, + }, + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + thread.update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test".into()), cx) + }); + + let mut mcp_tool_calls = setup_context_server( + "Superluminal", + vec![context_server::types::Tool { + name: "snake_case.PascalCase".into(), + title: None, + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }], + &context_server_store, + cx, + ); + + let events = thread.update(cx, |thread, cx| { + thread + .send(ClientUserMessageId::new(), ["Use the MCP tool"], cx) + .unwrap() + }); + cx.run_until_parked(); + + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + tool_names_for_completion(&completion), + vec!["snake_case_PascalCase"] + ); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_1".into(), + name: "snake_case_PascalCase".into(), + raw_input: json!({}).to_string(), + input: language_model::LanguageModelToolUseInput::Json(json!({})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); + assert_eq!(tool_call_params.name, "snake_case.PascalCase"); + tool_call_response + .send(context_server::types::CallToolResponse { + content: vec![context_server::types::ToolResponseContent::Text { + text: "done".into(), + }], + is_error: None, + meta: None, + structured_content: None, + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Done!"); + fake_model.end_last_completion_stream(); + events.collect::>().await; +} + #[gpui::test] async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { let ThreadTest { @@ -1695,7 +1794,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { id: "tool_1".into(), name: "screenshot".into(), raw_input: json!({}).to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1845,7 +1944,9 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp name: "issue_read".into(), raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}) .to_string(), - input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}), + ), is_input_complete: true, thought_signature: None, }, @@ -2260,7 +2361,9 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2355,7 +2458,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC id: "cancellation_aware_1".into(), name: "cancellation_aware".into(), raw_input: r#"{}"#.into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -2541,7 +2644,9 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) { id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2606,7 +2711,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2616,7 +2723,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) id: "terminal_tool_2".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 2000", "cd": "."}"#.into(), - input: json!({"command": "sleep 2000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 2000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2720,7 +2829,9 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2816,7 +2927,9 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) { id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": ".", "timeout_ms": 100}"#.into(), - input: json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}), + ), is_input_complete: true, thought_signature: None, }, @@ -3246,6 +3359,82 @@ async fn test_latest_token_usage_counts_cached_input_tokens(cx: &mut TestAppCont }); } +#[gpui::test] +async fn test_prompt_too_large_marks_token_usage_exceeded(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Message 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Response 1"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 100, + output_tokens: 50, + ..Default::default() + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.latest_token_usage().unwrap().ratio(), + acp_thread::TokenUsageRatio::Normal + ); + }); + + thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Message 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::PromptTooLarge { + tokens: None, + }); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + let usage = thread.latest_token_usage().unwrap(); + assert_eq!(usage.used_tokens, 1_000_000); + assert_eq!(usage.max_tokens, 1_000_000); + assert_eq!(usage.ratio(), acp_thread::TokenUsageRatio::Exceeded); + }); +} + +#[gpui::test] +async fn test_prompt_too_large_uses_reported_token_count(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Message 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::PromptTooLarge { + tokens: Some(1_500_000), + }); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + let usage = thread.latest_token_usage().unwrap(); + assert_eq!(usage.used_tokens, 1_500_000); + assert_eq!(usage.ratio(), acp_thread::TokenUsageRatio::Exceeded); + }); +} + #[gpui::test] async fn test_cumulative_token_usage(cx: &mut TestAppContext) { let ThreadTest { @@ -3285,7 +3474,7 @@ async fn test_cumulative_token_usage(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "hello"}).to_string(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -3653,6 +3842,11 @@ async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { thread.read_with(cx, |thread, _| { assert_eq!(thread.title(), None); assert!(thread.has_failed_title_generation()); + assert!( + thread + .title_generation_error() + .is_some_and(|error| error.contains("Internal server error")) + ); assert!(!thread.is_generating_title()); }); @@ -3663,6 +3857,7 @@ async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { thread.read_with(cx, |thread, _| { assert!(!thread.has_failed_title_generation()); + assert_eq!(thread.title_generation_error(), None); assert!(thread.is_generating_title()); }); @@ -3673,6 +3868,7 @@ async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { thread.read_with(cx, |thread, _| { assert_eq!(thread.title(), Some("Retried title".into())); assert!(!thread.has_failed_title_generation()); + assert_eq!(thread.title_generation_error(), None); assert!(!thread.is_generating_title()); }); } @@ -3695,7 +3891,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }; @@ -3703,7 +3899,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { id: "tool_id_2".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -3901,7 +4097,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { id: "1".into(), name: EchoTool::NAME.into(), raw_input: input.to_string(), - input, + input: language_model::LanguageModelToolUseInput::Json(input), is_input_complete: false, thought_signature: None, }, @@ -3914,7 +4110,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { id: "1".into(), name: "echo".into(), raw_input: input.to_string(), - input, + input: language_model::LanguageModelToolUseInput::Json(input), is_input_complete: true, thought_signature: None, }, @@ -4084,7 +4280,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -4232,7 +4428,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input( id: "tool_1".into(), name: "streaming_echo".into(), raw_input: r#"{"text": "partial"}"#.into(), - input: json!({"text": "partial"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})), is_input_complete: false, thought_signature: None, }; @@ -4337,7 +4533,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool( id: "tool_1".into(), name: StreamingJsonErrorContextTool::NAME.into(), raw_input: r#"{"text": "partial"#.into(), - input: json!({"text": "partial"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})), is_input_complete: false, thought_signature: None, }; @@ -5137,7 +5333,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5271,7 +5469,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5418,7 +5618,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5547,7 +5749,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5608,7 +5812,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { id: "subagent_2".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&resume_tool_input).unwrap(), - input: serde_json::to_value(&resume_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&resume_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6194,7 +6400,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6319,7 +6527,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6385,7 +6595,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu id: "subagent_2".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&resume_tool_input).unwrap(), - input: serde_json::to_value(&resume_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&resume_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6492,7 +6704,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -7056,6 +7270,12 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) invalid_patterns: vec![], }, ); + // The fetch tool also gates on the shared per-host network grant, so + // grant docs.rs to keep this URL fully silent. + settings + .sandbox_permissions + .network_hosts + .push("docs.rs".into()); agent_settings::AgentSettings::override_global(settings, cx); }); @@ -7075,7 +7295,369 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) let event = rx.try_recv(); assert!( !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), - "expected no authorization request for allowed docs.rs URL" + "expected no authorization request for allowed and granted docs.rs URL" + ); +} + +/// A fetch to a host that hasn't been granted network access prompts for the +/// shared per-host sandbox grant, even when the tool itself is allowed. +#[gpui::test] +async fn test_fetch_tool_prompts_for_ungranted_host(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let authorization = rx.expect_authorization().await; + let details = + acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) + .expect("an ungranted host should request a sandbox network grant"); + assert_eq!(details.network_hosts, vec!["example.com".to_string()]); + assert!(!details.network_all_hosts); +} + +/// A host already present in the shared sandbox grants lets a fetch proceed +/// without any prompt — the same grant the terminal tool records and consults. +#[gpui::test] +async fn test_fetch_tool_granted_host_skips_prompt(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + // Allow the tool itself so only the shared per-host grant is under test. + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let event = rx.try_recv(); + assert!( + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), + "expected no authorization request for an already-granted host" + ); +} + +/// Loopback / IP-literal hosts can't be granted individually, so without +/// unsandboxed access a fetch to them is refused with guidance to grant it. +#[gpui::test] +async fn test_fetch_tool_refuses_loopback_without_unsandboxed(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + // Allow the tool itself so the request reaches the per-host gate. + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, _rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert!(result.is_err(), "expected a loopback fetch to be refused"); + assert!( + result.unwrap_err().contains("unsandboxed"), + "error should point at unsandboxed access as the way to reach loopback hosts" + ); +} + +/// Granting unsandboxed access lifts every fetch restriction, matching the +/// terminal: even loopback hosts become reachable and no per-host prompt is +/// requested. +#[gpui::test] +async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.sandbox_permissions.allow_unsandboxed = true; + // Allow the tool itself so only the per-host gate is under test. + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + // A loopback host that could never be granted individually is reachable, + // and no per-host authorization is requested. + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let event = rx.try_recv(); + assert!( + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), + "expected no authorization request when unsandboxed access is granted" + ); +} + +/// A granted host that redirects to a loopback target must not have that +/// redirect followed: loopback hosts can't be granted individually, so the hop +/// is refused just like a direct loopback fetch. This is the redirect variant of +/// the SSRF protection — the approved domain can't be used to bounce the request +/// onto the local machine. +#[gpui::test] +async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + assert!( + uri.contains("example.com"), + "the loopback redirect target must never be requested, but saw {uri}" + ); + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "http://localhost:3000/internal") + .body("".into()) + .unwrap()) + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, _rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert!( + result.is_err(), + "expected a redirect to a loopback host to be refused" + ); + assert!( + result.unwrap_err().contains("unsandboxed"), + "error should point at unsandboxed access as the way to reach loopback hosts" + ); +} + +/// A granted host that redirects to a *different*, ungranted host triggers a +/// fresh per-host authorization prompt for the redirect target — the redirect is +/// not silently followed to a host the user never approved. +#[gpui::test] +async fn test_fetch_tool_reauthorizes_redirect_to_new_host(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + assert!( + uri.contains("example.com"), + "the ungranted redirect target must not be requested before authorization, \ + but saw {uri}" + ); + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "https://redirect-target.example/landing") + .body("".into()) + .unwrap()) + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let authorization = rx.expect_authorization().await; + let details = + acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) + .expect("a redirect to an ungranted host should request a sandbox network grant"); + assert_eq!( + details.network_hosts, + vec!["redirect-target.example".to_string()] + ); + assert!(!details.network_all_hosts); +} + +/// Redirects between paths on an already-granted host are followed without any +/// additional prompt, so ordinary redirects (http→https upgrades, trailing-slash +/// canonicalization, etc.) keep working after the per-hop authorization change. +#[gpui::test] +async fn test_fetch_tool_follows_same_host_redirect(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + if uri.ends_with("/start") { + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "https://example.com/final") + .body("".into()) + .unwrap()) + } else if uri.ends_with("/final") { + Ok(gpui::http_client::Response::builder() + .status(200) + .header("content-type", "text/plain") + .body("final content".into()) + .unwrap()) + } else { + panic!("unexpected request to {uri}"); + } + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert_eq!( + result.expect("same-host redirect should succeed"), + "final content" + ); + + let event = rx.try_recv(); + assert!( + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), + "expected no authorization prompt for a redirect to an already-granted host" ); } @@ -7101,7 +7683,7 @@ async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppConte id: id.into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7180,7 +7762,7 @@ async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut Tes id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7251,7 +7833,7 @@ async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestApp id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7327,7 +7909,7 @@ async fn test_unrelated_settings_change_does_not_resolve_pending_authorization( id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7397,7 +7979,7 @@ async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mu id: id.into(), name: name.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7494,7 +8076,7 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text": "hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -7576,7 +8158,7 @@ async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut Te id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text": "hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -7643,7 +8225,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA id: "call_1".into(), name: StreamingFailingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: false, thought_signature: None, }; @@ -7725,7 +8307,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te id: "call_1".into(), name: StreamingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({ "text": "hello" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })), is_input_complete: false, thought_signature: None, }, @@ -7734,7 +8316,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te id: "call_1".into(), name: StreamingEchoTool::NAME.into(), raw_input: "hello world".into(), - input: json!({ "text": "hello world" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello world" })), is_input_complete: true, thought_signature: None, }; @@ -7744,7 +8326,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te let second_tool_use = LanguageModelToolUse { name: StreamingFailingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({ "text": "hello" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })), is_input_complete: false, thought_signature: None, id: "call_2".into(), @@ -7873,7 +8455,7 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text":"hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 704e48eef5e636..585e9eb82ae375 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -12,14 +12,14 @@ use action_log::ActionLog; use agent_settings::UserAgentsMd; use crate::sandboxing::{ - SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandboxing_available_for_project, + SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandbox_git_dirs, + sandbox_worktree_writable_paths, sandboxing_available_for_project, sandboxing_enabled_for_project, }; -use crate::tools::{SandboxGitPathCandidates, sandbox_git_paths}; use agent_client_protocol::schema::v1 as acp; use agent_settings::{ - AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, - SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, + AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, builtin_profiles, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Local, Utc}; @@ -35,7 +35,8 @@ use futures::{ }; use futures::{StreamExt, stream}; use gpui::{ - App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, + App, AppContext, AsyncApp, Context, Entity, EventEmitter, ReadGlobal as _, SharedString, Task, + WeakEntity, }; use heck::ToSnakeCase as _; use language_model::{ @@ -46,7 +47,7 @@ use language_model::{ LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, SelectedModel, Speed, StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID, }; -use project::Project; +use project::{Project, trusted_worktrees::TrustedWorktrees}; use prompt_store::ProjectContext; use schemars::{JsonSchema, Schema}; use serde::de::DeserializeOwned; @@ -72,15 +73,33 @@ const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; pub const MAX_TOOL_NAME_LENGTH: usize = 64; pub const MAX_SUBAGENT_DEPTH: u8 = 1; +pub(crate) fn provider_compatible_tool_name(tool_name: &str) -> String { + let mut sanitized = String::new(); + for character in tool_name.chars() { + if sanitized.len() >= MAX_TOOL_NAME_LENGTH { + break; + } + + if character.is_ascii_alphanumeric() || character == '_' || character == '-' { + sanitized.push(character); + } else { + sanitized.push('_'); + } + } + + if sanitized.is_empty() { + sanitized.push_str("tool"); + } + + sanitized +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct SandboxStatusKey { pub settings_sandbox: ThreadSandbox, pub thread_sandbox: ThreadSandbox, pub baseline_writable_paths: Vec, pub git_paths: Vec, - pub repository_paths: Vec<(PathBuf, PathBuf, PathBuf, PathBuf)>, - pub settings_allow_git_access: bool, - pub thread_allow_git_access: bool, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -588,7 +607,7 @@ impl AgentMessage { "{}\n", MarkdownCodeBlock { tag: "json", - text: &format!("{:#}", tool_use.input) + text: &format!("{:#}", tool_use.input.to_display_json()) } )); } @@ -1163,7 +1182,7 @@ enum CompletionError { Other(#[from] anyhow::Error), } -pub(crate) enum ThreadModel { +pub enum ThreadModel { Ready(Arc), Unresolved(SelectedModel), Unset, @@ -1200,7 +1219,7 @@ pub struct Thread { updated_at: DateTime, title: Option, pending_title_generation: Option>, - title_generation_failed: bool, + title_generation_error: Option, pending_summary_generation: Option>>>, summary: Option, messages: Vec>, @@ -1226,6 +1245,9 @@ pub struct Thread { initial_project_snapshot: Shared>>>, pub(crate) context_server_registry: Entity, profile_id: AgentProfileId, + /// Whether `profile_id` was downgraded to `minimal` at thread start because + /// the workspace is restricted. Used purely to surface a warning in the UI. + profile_downgraded_for_restricted_workspace: bool, project_context: Entity, pub(crate) templates: Arc, model: ThreadModel, @@ -1320,7 +1342,8 @@ impl Thread { cx: &mut Context, ) -> Self { let settings = AgentSettings::get_global(cx); - let profile_id = settings.default_profile.clone(); + let (profile_id, profile_downgraded_for_restricted_workspace) = + Self::profile_for_restricted_workspace(settings.default_profile.clone(), &project, cx); let enable_thinking = settings .default_model .as_ref() @@ -1335,14 +1358,18 @@ impl Thread { .and_then(|model| model.speed); let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); - let model = model.map_or(ThreadModel::Unset, ThreadModel::Ready); + let model = match model { + Some(model) => ThreadModel::Ready(model), + None => Self::user_configured_model_selection(cx) + .map_or(ThreadModel::Unset, ThreadModel::Unresolved), + }; Self { id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), prompt_id: PromptId::new(), updated_at: Utc::now(), title: None, pending_title_generation: None, - title_generation_failed: false, + title_generation_error: None, pending_summary_generation: None, summary: None, messages: Vec::new(), @@ -1363,6 +1390,7 @@ impl Thread { }, context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace, project_context, templates, model, @@ -1395,6 +1423,8 @@ impl Thread { self.thinking_effort = parent.thinking_effort.clone(); self.summarization_model = parent.summarization_model.clone(); self.profile_id = parent.profile_id.clone(); + self.profile_downgraded_for_restricted_workspace = + parent.profile_downgraded_for_restricted_workspace; } fn apply_model_selection( @@ -1563,7 +1593,7 @@ impl Thread { .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) .status(status) - .raw_input(tool_use.input.clone()), + .raw_input(tool_use.input.to_display_json()), ))) .ok(); let mut fields = acp::ToolCallUpdateFields::new() @@ -1576,15 +1606,12 @@ impl Thread { return; }; - let title = tool.initial_title(tool_use.input.clone(), cx); + let Ok(input) = tool_use.input.clone().into_json() else { + return; + }; + let title = tool.initial_title(input.clone(), cx); let kind = tool.kind(); - stream.send_tool_call( - &tool_use.id, - &tool_use.name, - title, - kind, - tool_use.input.clone(), - ); + stream.send_tool_call(&tool_use.id, &tool_use.name, title, kind, input.clone()); if let Some(content) = replay_content { stream.update_tool_call_fields( @@ -1605,8 +1632,7 @@ impl Thread { self.sandbox_grants.clone(), Some(cx.weak_entity()), ); - tool.replay(tool_use.input.clone(), output, tool_event_stream, cx) - .log_err(); + tool.replay(input, output, tool_event_stream, cx).log_err(); } stream.update_tool_call_fields( @@ -1722,7 +1748,7 @@ impl Thread { Some(db_thread.title.clone()) }, pending_title_generation: None, - title_generation_failed: false, + title_generation_error: None, pending_summary_generation: None, summary: db_thread.detailed_summary, messages: db_thread.messages, @@ -1738,6 +1764,7 @@ impl Thread { initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace: false, project_context, templates, model, @@ -1765,21 +1792,16 @@ impl Thread { } } - /// The sandbox grants configured for this thread, using unverified Git path - /// candidates. Use [`Self::refresh_verified_sandbox_status`] for UI or other - /// surfaces that need to match terminal enforcement. pub fn sandbox_status(&self, cx: &App) -> Option<(ThreadSandbox, ThreadSandbox)> { if !self.sandboxing_available(cx) { return None; } let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone(); - let git_dirs = crate::sandboxing::sandbox_git_dirs(self.project.read(cx), cx); + let git_dirs = sandbox_git_dirs(self.project.read(cx), cx); let grants = self.sandbox_grants.borrow(); let settings = crate::sandboxing::settings_thread_sandbox(&persistent) - .with_git(persistent.allow_git_access, git_dirs.clone()); - let thread = grants - .thread_sandbox() - .with_git(grants.git_access_granted(), git_dirs); + .with_protected_paths(git_dirs.clone()); + let thread = grants.thread_sandbox().with_protected_paths(git_dirs); Some((settings, thread)) } @@ -1795,69 +1817,27 @@ impl Thread { let settings_sandbox = crate::sandboxing::settings_thread_sandbox(&persistent); let grants = self.sandbox_grants.borrow(); let thread_sandbox = grants.thread_sandbox(); - let thread_allow_git_access = grants.git_access_granted(); drop(grants); - let (sandbox_path_candidates, fs) = { - let project = self.project.read(cx); - ( - SandboxGitPathCandidates::from_project(project, cx), - project.fs().clone(), - ) - }; - let baseline_writable_paths = sandbox_path_candidates.writable_paths.clone(); - let git_paths = sandbox_path_candidates.git_paths.clone(); - let repository_paths = sandbox_path_candidates.cache_key_repositories(); + let project = self.project.read(cx); + let baseline_writable_paths = sandbox_worktree_writable_paths(project, cx); + let git_paths = sandbox_git_dirs(project, cx); let key = SandboxStatusKey { settings_sandbox: settings_sandbox.clone(), thread_sandbox: thread_sandbox.clone(), baseline_writable_paths: baseline_writable_paths.clone(), git_paths: git_paths.clone(), - repository_paths, - settings_allow_git_access: persistent.allow_git_access, - thread_allow_git_access, }; - if settings_sandbox.is_unsandboxed() || thread_sandbox.is_unsandboxed() { - return Some(( - key, - SandboxStatusRefresh::Ready(VerifiedSandboxStatus { - settings_sandbox, - thread_sandbox, - baseline_writable_paths, - }), - )); - } - - let git_access_requested = persistent.allow_git_access || thread_allow_git_access; - if !git_access_requested { - return Some(( - key, - SandboxStatusRefresh::Ready(VerifiedSandboxStatus { - settings_sandbox: settings_sandbox.with_git(false, git_paths.clone()), - thread_sandbox: thread_sandbox.with_git(false, git_paths), - baseline_writable_paths, - }), - )); - } - - let task = cx.spawn(async move |_this, _cx| { - let sandbox_paths = sandbox_git_paths(sandbox_path_candidates, fs.as_ref(), true).await; - VerifiedSandboxStatus { - settings_sandbox: settings_sandbox.with_git( - persistent.allow_git_access && sandbox_paths.allow_git_access, - sandbox_paths.git_dirs.clone(), - ), - thread_sandbox: thread_sandbox.with_git( - thread_allow_git_access && sandbox_paths.allow_git_access, - sandbox_paths.git_dirs, - ), + Some(( + key, + SandboxStatusRefresh::Ready(VerifiedSandboxStatus { + settings_sandbox: settings_sandbox.with_protected_paths(git_paths.clone()), + thread_sandbox: thread_sandbox.with_protected_paths(git_paths), baseline_writable_paths, - } - }); - - Some((key, SandboxStatusRefresh::Pending(task))) + }), + )) } /// Whether agent terminal commands are sandboxed for this thread's project, @@ -1866,12 +1846,12 @@ impl Thread { sandboxing_enabled_for_project(self.project.read(cx), cx) } - /// Whether sandboxing is *applicable* for this thread's project (feature on, - /// local project, supported platform), regardless of whether it's been - /// turned off in settings. The UI shows the sandbox indicator whenever this - /// is true, drawing it struck-out when sandboxing is disabled. + /// Whether sandboxing is *applicable* for this thread's project (local + /// project, supported platform), regardless of whether it's been turned off + /// in settings. The UI shows the sandbox indicator whenever this is true, + /// drawing it struck-out when sandboxing is disabled. pub fn sandboxing_available(&self, cx: &App) -> bool { - sandboxing_available_for_project(self.project.read(cx), cx) + sandboxing_available_for_project(self.project.read(cx)) } /// The directory subtrees the sandbox always grants write access to for this @@ -1967,6 +1947,10 @@ impl Thread { self.model.as_model() } + pub fn thread_model(&self) -> &ThreadModel { + &self.model + } + pub(crate) fn ensure_model( &mut self, default_model: Option<&Arc>, @@ -1998,6 +1982,7 @@ impl Thread { cx.emit(TokenUsageUpdated(new_usage)); } self.prompt_capabilities_tx.send(new_caps).log_err(); + cx.emit(ModelChanged); for subagent in &self.running_subagents { subagent @@ -2195,7 +2180,44 @@ impl Thread { &self.profile_id } + /// Whether this thread's profile was downgraded to `minimal` at thread start + /// because the workspace is restricted. + pub fn profile_was_downgraded(&self) -> bool { + self.profile_downgraded_for_restricted_workspace + } + + /// Computes the profile a thread should start with, given the user's chosen + /// profile. In a restricted workspace, the built-in `write`/`ask` profiles + /// are downgraded to `minimal` — but only when both the chosen profile and + /// `minimal` are unmodified, shipped defaults, so we never override a user's + /// custom or customized profiles. + /// + /// Returns the (possibly downgraded) profile and whether a downgrade + /// happened. + fn profile_for_restricted_workspace( + profile_id: AgentProfileId, + project: &Entity, + cx: &App, + ) -> (AgentProfileId, bool) { + let is_write_or_ask = profile_id.as_str() == builtin_profiles::WRITE + || profile_id.as_str() == builtin_profiles::ASK; + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + if is_write_or_ask + && TrustedWorktrees::has_restricted_worktrees(&project.read(cx).worktree_store(), cx) + && AgentProfileSettings::is_unmodified_default(&profile_id, cx) + && AgentProfileSettings::is_unmodified_default(&minimal, cx) + { + (minimal, true) + } else { + (profile_id, false) + } + } + pub fn set_profile(&mut self, profile_id: AgentProfileId, cx: &mut Context) { + // An explicit selection means any earlier automatic downgrade no longer + // applies, even if the user re-selects the same profile. + self.profile_downgraded_for_restricted_workspace = false; + if self.profile_id == profile_id { return; } @@ -2292,6 +2314,31 @@ impl Thread { cx.notify(); } + /// Records that the last request overflowed the model's context window so + /// the token usage indicator reports `Exceeded` instead of the stale usage + /// from the last successful request. Providers don't report usage for + /// failed requests, so we synthesize one from the reported overflow (when + /// available) or the model's context size. + fn mark_token_limit_exceeded(&mut self, tokens: Option, cx: &mut Context) { + let Some(model) = self.model() else { + return; + }; + let input_tokens = tokens.unwrap_or(0).max(model.max_token_count()); + let Some(last_user_message) = self.last_user_message() else { + return; + }; + + self.request_token_usage.insert( + last_user_message.id.clone(), + language_model::TokenUsage { + input_tokens, + ..Default::default() + }, + ); + cx.emit(TokenUsageUpdated(self.latest_token_usage())); + cx.notify(); + } + pub fn truncate( &mut self, client_user_message_id: ClientUserMessageId, @@ -2379,6 +2426,20 @@ impl Thread { Self::resolve_model_from_selection(&selection, cx) } + fn user_configured_model_selection(cx: &App) -> Option { + let selection = SettingsStore::global(cx) + .raw_user_settings()? + .content + .agent + .as_ref()? + .default_model + .as_ref()?; + Some(SelectedModel { + provider: LanguageModelProviderId::from(selection.provider.0.clone()), + model: LanguageModelId::from(selection.model.clone()), + }) + } + /// Translate a stored model selection into the configured model from the registry. fn resolve_model_from_selection( selection: &LanguageModelSelection, @@ -3002,7 +3063,7 @@ impl Thread { ) -> Result> { let retry = this.update(cx, |this, cx| { let user_store = this.user_store.read(cx); - this.handle_completion_error(error, attempt, user_store.plan()) + this.handle_completion_error(error, attempt, user_store.plan(), cx) })??; let timer = cx.background_executor().timer(retry.duration); event_stream.send_retry(retry); @@ -3192,7 +3253,12 @@ impl Thread { error: LanguageModelCompletionError, attempt: u8, plan: Option, + cx: &mut Context, ) -> Result { + if let LanguageModelCompletionError::PromptTooLarge { tokens } = &error { + self.mark_token_limit_exceeded(*tokens, cx); + } + let Some(model) = self.model() else { return Err(anyhow!(error)); }; @@ -3378,7 +3444,9 @@ impl Thread { let mut title = SharedString::from(&tool_use.name); let mut kind = acp::ToolKind::Other; if let Some(tool) = tool.as_ref() { - title = tool.initial_title(tool_use.input.clone(), cx); + if let Ok(input) = tool_use.input.clone().into_json() { + title = tool.initial_title(input, cx); + } kind = tool.kind(); } @@ -3395,16 +3463,33 @@ impl Thread { })); }; + // Agent tools are JSON-schema tools. Custom text-tool deltas are rejected + // before considering partial-vs-complete input for these local tools. + let input = match tool_use.input.clone().into_json() { + Ok(input) => input, + Err(error) => { + return Some(Task::ready(LanguageModelToolResult { + content: vec![LanguageModelToolResultContent::Text(Arc::from( + error.to_string(), + ))], + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + })); + } + }; + if !tool_use.is_input_complete { if tool.supports_input_streaming() { let running_turn = self.running_turn.as_mut()?; if let Some(sender) = running_turn.streaming_tool_inputs.get_mut(&tool_use.id) { - sender.send_partial(tool_use.input); + sender.send_partial(input); return None; } let (mut sender, tool_input) = ToolInputSender::channel(); - sender.send_partial(tool_use.input); + sender.send_partial(input); running_turn .streaming_tool_inputs .insert(tool_use.id.clone(), sender); @@ -3431,12 +3516,12 @@ impl Thread { .streaming_tool_inputs .remove(&tool_use.id) { - sender.send_full(tool_use.input); + sender.send_full(input); return None; } log::debug!("Running tool {}", tool_use.name); - let tool_input = ToolInput::ready(tool_use.input); + let tool_input = ToolInput::ready(input); Some(self.run_tool( tool, tool_input, @@ -3458,6 +3543,26 @@ impl Thread { cancellation_rx: watch::Receiver, cx: &mut Context, ) -> Task { + // A workspace can become restricted after a thread has already started. + // Tools that aren't allowed in restricted workspaces must never run in + // that state, even though they were exposed to the model earlier. + if !tool.allow_in_restricted_mode() + && TrustedWorktrees::has_restricted_worktrees( + &self.project.read(cx).worktree_store(), + cx, + ) + { + return Task::ready(LanguageModelToolResult { + tool_use_id, + tool_name, + is_error: true, + content: vec![LanguageModelToolResultContent::Text(Arc::from( + "workspace has become restricted", + ))], + output: None, + }); + } + let fs = self.project.read(cx).fs().clone(); let tool_event_stream = ToolCallEventStream::new( tool_use_id.clone(), @@ -3540,7 +3645,7 @@ impl Thread { id: tool_use_id, name: tool_name, raw_input: raw_input.to_string(), - input: serde_json::json!({}), + input: language_model::LanguageModelToolUseInput::Json(serde_json::json!({})), is_input_complete: true, thought_signature: None, }; @@ -3616,7 +3721,7 @@ impl Thread { &tool_use.name, title, kind, - tool_use.input.clone(), + tool_use.input.to_display_json(), ); last_message .content @@ -3627,7 +3732,7 @@ impl Thread { acp::ToolCallUpdateFields::new() .title(title.as_str()) .kind(kind) - .raw_input(tool_use.input.clone()), + .raw_input(tool_use.input.to_display_json()), None, ); } @@ -3646,7 +3751,11 @@ impl Thread { } pub fn has_failed_title_generation(&self) -> bool { - self.title_generation_failed + self.title_generation_error.is_some() + } + + pub fn title_generation_error(&self) -> Option { + self.title_generation_error.clone() } pub fn can_generate_title(&self) -> bool { @@ -3749,7 +3858,7 @@ impl Thread { on_generated_title: Option)>>, cx: &mut Context, ) { - self.title_generation_failed = false; + self.title_generation_error = None; log::debug!("Generating title with model: {:?}", model.name()); let temperature = AgentSettings::temperature_for_model(&model, cx); @@ -3760,22 +3869,26 @@ impl Thread { .await .context("failed to generate thread title") .map(SharedString::from) - .log_err() }); self.pending_title_generation = Some(cx.spawn(async move |this, cx| { let title = title_generation.await; _ = this.update(cx, |this, cx| { this.pending_title_generation = None; - if let Some(title) = title { - this.set_title(title.clone(), cx); - if let Some(on_generated_title) = on_generated_title { - on_generated_title(title, cx); + match title { + Ok(title) => { + this.set_title(title.clone(), cx); + if let Some(on_generated_title) = on_generated_title { + on_generated_title(title, cx); + } + } + Err(error) => { + let error = format!("{error:#}"); + log::error!("{error}"); + this.title_generation_error = Some(error.into()); + cx.emit(TitleUpdated); + cx.notify(); } - } else { - this.title_generation_failed = true; - cx.emit(TitleUpdated); - cx.notify(); } }); })); @@ -3784,7 +3897,7 @@ impl Thread { pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { self.pending_title_generation = None; - self.title_generation_failed = false; + self.title_generation_error = None; if Some(&title) != self.title.as_ref() { self.title = Some(title); cx.emit(TitleUpdated); @@ -3867,12 +3980,12 @@ impl Thread { .iter() .filter_map(|(tool_name, tool)| { log::trace!("Including tool: {}", tool_name); - Some(LanguageModelRequestTool { - name: tool_name.to_string(), - description: tool.description().to_string(), - input_schema: tool.input_schema(model.tool_input_format()).log_err()?, - use_input_streaming: tool.supports_input_streaming(), - }) + Some(LanguageModelRequestTool::function( + tool_name.to_string(), + tool.description().to_string(), + tool.input_schema(model.tool_input_format()).log_err()?, + tool.supports_input_streaming(), + )) }) .collect::>() } else { @@ -3921,24 +4034,21 @@ impl Thread { let Some(profile) = AgentSettings::get_global(cx).profiles.get(&self.profile_id) else { return BTreeMap::new(); }; - fn truncate(tool_name: &SharedString) -> SharedString { - if tool_name.len() > MAX_TOOL_NAME_LENGTH { - let mut truncated = tool_name.to_string(); - truncated.truncate(MAX_TOOL_NAME_LENGTH); - truncated.into() - } else { - tool_name.clone() - } - } - // Terminal variants are configured by users under the canonical // `terminal` name. Expose the one matching the current sandbox state // to the model under that name. let use_sandboxed_terminal = sandboxing_enabled_for_project(self.project.read(cx), cx); + // Tools that aren't allowed in restricted workspaces must never be + // provided to the model while the workspace is restricted, regardless + // of what the active profile enables. + let is_restricted = + TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); + let mut tools = self .tools .iter() + .filter(|(_, tool)| !is_restricted || tool.allow_in_restricted_mode()) .filter_map(|(tool_name, tool)| { let terminal_variant = matches!( tool_name.as_ref(), @@ -3958,7 +4068,10 @@ impl Thread { Some((SharedString::from(TerminalTool::NAME), tool.clone())) } (TerminalTool::NAME | SandboxedTerminalTool::NAME, _) => None, - _ => Some((truncate(tool_name), tool.clone())), + _ => Some(( + provider_compatible_tool_name(tool_name.as_ref()).into(), + tool.clone(), + )), } } else { None @@ -3973,7 +4086,8 @@ impl Thread { for (server_id, server_tools) in self.context_server_registry.read(cx).servers() { for (tool_name, tool) in server_tools { if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) { - let tool_name = truncate(tool_name); + let tool_name: SharedString = + provider_compatible_tool_name(tool_name.as_ref()).into(); if !seen_tools.insert(tool_name.clone()) { duplicate_tool_names.insert(tool_name.clone()); } @@ -3990,7 +4104,8 @@ impl Thread { if duplicate_tool_names.contains(&tool_name) { let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); if available >= 2 { - let mut disambiguated = server_id.0.to_snake_case(); + let mut disambiguated = + provider_compatible_tool_name(&server_id.0.to_snake_case()).to_string(); disambiguated.truncate(available - 1); disambiguated.push('_'); disambiguated.push_str(&tool_name); @@ -4729,6 +4844,10 @@ pub struct TitleUpdated; impl EventEmitter for Thread {} +pub struct ModelChanged; + +impl EventEmitter for Thread {} + /// A channel-based wrapper that delivers tool input to a running tool. /// /// For non-streaming tools, created via `ToolInput::ready()` so `.recv()` resolves immediately. @@ -4904,6 +5023,14 @@ where true } + /// Whether this tool may be provided to an agent in a restricted workspace. + /// + /// Tools that return `false` are never exposed to the model while the + /// workspace is restricted, and will fail if invoked in that state. + fn allow_in_restricted_mode() -> bool { + true + } + /// Runs the tool with the provided input. /// /// Returns `Result` rather than `Result` @@ -4967,6 +5094,9 @@ pub trait AnyAgentTool { fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { true } + fn allow_in_restricted_mode(&self) -> bool { + true + } /// See [`AgentTool::run`] for why this returns `Result`. fn run( self: Arc, @@ -5018,6 +5148,10 @@ where T::supports_provider(provider) } + fn allow_in_restricted_mode(&self) -> bool { + T::allow_in_restricted_mode() + } + fn run( self: Arc, input: ToolInput, @@ -5547,7 +5681,6 @@ impl ToolCallEventStream { command: None, network_hosts, network_all_hosts, - allow_git_access: request.allow_git_access, allow_fs_write_all: request.allow_fs_write_all, unsandboxed: request.unsandboxed, write_paths: request.write_paths.clone(), @@ -5752,9 +5885,7 @@ impl ToolCallEventStream { agent.set_sandbox_network_hosts(host_strings); } } - if request.allow_git_access { - agent.allow_sandbox_git_access(); - } + if request.allow_fs_write_all { agent.allow_sandbox_fs_write_all(); } @@ -5798,6 +5929,20 @@ impl ToolCallEventStream { self.sandbox_grants.borrow().unsandboxed_granted() } + /// Whether unsandboxed access is currently in effect: granted for this + /// thread (a model-requested `unsandboxed` escape or a sandbox-creation + /// fallback) or configured persistently via `allow_unsandboxed`. When true, + /// commands already run without any OS sandbox, so per-host network grants + /// no longer provide isolation and callers may skip host authorization + /// entirely. + pub(crate) fn unsandboxed_access_granted(&self, cx: &App) -> bool { + self.unsandboxed_granted_for_thread() + || self.sandbox_fallback_granted_for_thread() + || AgentSettings::get_global(cx) + .sandbox_permissions + .allow_unsandboxed + } + /// Ask the user how to proceed when the OS sandbox could not be created /// for a command (for example, `bwrap` is missing or user namespaces are /// disabled). @@ -5819,10 +5964,15 @@ impl ToolCallEventStream { &self, command: Option, reason: String, + docs_section: Option, retries: usize, cx: &mut App, ) -> Task> { - let details = acp_thread::SandboxFallbackAuthorizationDetails { command, reason }; + let details = acp_thread::SandboxFallbackAuthorizationDetails { + command, + reason, + docs_section, + }; let retry_label = if retries == 0 { "Retry".to_string() } else { @@ -7489,7 +7639,6 @@ mod tests { let (event_stream, mut receiver) = ToolCallEventStream::test(); let request = SandboxRequest { network: crate::sandboxing::NetworkRequest::None, - allow_git_access: false, allow_fs_write_all: false, unsandboxed: false, write_paths: vec![ @@ -7513,7 +7662,6 @@ mod tests { .expect("sandbox authorization should include request details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); - assert_eq!(details.allow_git_access, request.allow_git_access); assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all); assert_eq!(details.unsandboxed, request.unsandboxed); assert_eq!(details.write_paths, request.write_paths); @@ -7581,6 +7729,7 @@ mod tests { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "bwrap not found on PATH".to_string(), + Some("installing-bubblewrap".to_string()), 0, cx, ) @@ -7592,6 +7741,10 @@ mod tests { .expect("fallback authorization should include details"); assert_eq!(details.command.as_deref(), Some("cargo build")); assert_eq!(details.reason, "bwrap not found on PATH"); + assert_eq!( + details.docs_section.as_deref(), + Some("installing-bubblewrap") + ); let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { panic!("expected flat fallback permission options"); @@ -7632,6 +7785,7 @@ mod tests { event_stream.authorize_sandbox_fallback( None, "probe failed".to_string(), + None, retries, cx, ) @@ -7676,6 +7830,7 @@ mod tests { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "user namespaces are disabled".to_string(), + None, 0, cx, ) @@ -7705,7 +7860,13 @@ mod tests { let (event_stream, mut receiver) = ToolCallEventStream::test(); let authorize = cx.update(|cx| { - event_stream.authorize_sandbox_fallback(None, "bwrap probe failed".to_string(), 0, cx) + event_stream.authorize_sandbox_fallback( + None, + "bwrap probe failed".to_string(), + None, + 0, + cx, + ) }); let authorization = receiver.expect_authorization().await; authorization @@ -7780,7 +7941,7 @@ mod tests { id: registered_tool_use_id.clone(), name: ReplayImageTool::NAME.into(), raw_input: "null".to_string(), - input: json!(null), + input: language_model::LanguageModelToolUseInput::Json(json!(null)), is_input_complete: true, thought_signature: None, }; @@ -7788,7 +7949,7 @@ mod tests { id: missing_tool_use_id.clone(), name: "missing_image_tool".into(), raw_input: "{}".to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }; @@ -8095,7 +8256,10 @@ mod tests { assert_eq!(tool_use.raw_input, raw_input.to_string()); assert!(tool_use.is_input_complete); // Should fall back to empty object for invalid JSON - assert_eq!(tool_use.input, json!({})); + assert_eq!( + tool_use.input, + language_model::LanguageModelToolUseInput::Json(json!({})) + ); } _ => panic!("Expected ToolUse content"), } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 42d47a13275345..85da6bbb214dcb 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -84,7 +84,7 @@ pub use rename_tool::*; pub use skill_tool::*; pub use spawn_agent_tool::*; pub use symbol_locator::*; -pub(crate) use terminal_tool::sandbox_git_paths::{SandboxGitPathCandidates, sandbox_git_paths}; + pub use terminal_tool::*; pub use tool_permissions::*; pub use web_search_tool::*; @@ -138,15 +138,27 @@ macro_rules! tools { false } + /// Returns whether the tool with the given name may be provided to an + /// agent in a restricted workspace. Unknown tools (e.g. MCP tools) are + /// considered allowed. + pub fn tool_allowed_in_restricted_mode(name: &str) -> bool { + $( + if name == <$tool>::NAME { + return <$tool>::allow_in_restricted_mode(); + } + )* + true + } + /// A list of all built-in tools pub fn built_in_tools() -> impl Iterator { fn language_model_tool() -> LanguageModelRequestTool { - LanguageModelRequestTool { - name: T::NAME.to_string(), - description: T::description().to_string(), - input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), - use_input_streaming: T::supports_input_streaming(), - } + LanguageModelRequestTool::function( + T::NAME.to_string(), + T::description().to_string(), + T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), + T::supports_input_streaming(), + ) } [ $( @@ -219,3 +231,25 @@ pub fn tool_feature_flag_enabled(tool_name: &str, cx: &App) -> bool { _ => true, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fetch_and_terminal_are_forbidden_in_restricted_mode() { + assert!(!tool_allowed_in_restricted_mode(FetchTool::NAME)); + assert!(!tool_allowed_in_restricted_mode(TerminalTool::NAME)); + + // Every other built-in tool, and unknown (e.g. MCP) tools, are allowed. + for name in ALL_TOOL_NAMES { + let expected = *name != FetchTool::NAME && *name != TerminalTool::NAME; + assert_eq!( + tool_allowed_in_restricted_mode(name), + expected, + "unexpected restricted-mode policy for tool `{name}`" + ); + } + assert!(tool_allowed_in_restricted_mode("some_mcp_tool")); + } +} diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 522b5779291f51..808535a713593e 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -8,7 +8,11 @@ use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedStrin use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use std::sync::Arc; -use util::ResultExt; +use util::{ResultExt, markdown::MarkdownEscaped}; + +/// Maximum number of characters to show from a tool argument in the +/// collapsed tool-call header. Longer values are truncated with an ellipsis. +const MAX_INLINE_ARG_LEN: usize = 120; /// Generates a tool ID for an MCP tool that can be used in settings. /// @@ -310,8 +314,8 @@ impl AnyAgentTool for ContextServerTool { acp::ToolKind::Other } - fn initial_title(&self, _input: serde_json::Value, _cx: &mut App) -> SharedString { - format!("Run MCP tool `{}`", self.tool.name).into() + fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString { + format_mcp_initial_title(&self.tool.name, &input).into() } fn input_schema( @@ -474,6 +478,38 @@ impl AnyAgentTool for ContextServerTool { } } +/// Builds the header label shown for an MCP tool call. When the input is an +/// object with a single string-valued field, the value is inlined next to the +/// tool name so the primary argument (e.g. a URL, path, or query) is visible +/// without expanding the input block — matching the UX of built-in tools like +/// `Fetch`. All other shapes fall back to the tool name alone. +fn format_mcp_initial_title(tool_name: &str, input: &serde_json::Value) -> String { + if let Some(value) = single_string_arg(input) { + let preview = truncate_chars(value, MAX_INLINE_ARG_LEN); + format!("Run MCP tool `{}` {}", tool_name, MarkdownEscaped(&preview)) + } else { + format!("Run MCP tool `{}`", tool_name) + } +} + +fn single_string_arg(input: &serde_json::Value) -> Option<&str> { + let obj = input.as_object()?; + if obj.len() != 1 { + return None; + } + obj.values().next()?.as_str() +} + +fn truncate_chars(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max).collect(); + out.push('…'); + out + } +} + pub fn get_prompt( server_store: &Entity, server_id: &ContextServerId, @@ -531,4 +567,92 @@ mod tests { // Note: Tests for MCP tool ID collision with built-in tools and permission // decisions are in crates/agent/src/tool_permissions.rs to avoid duplication. + + #[test] + fn test_format_mcp_initial_title_inlines_single_string_arg() { + let input = serde_json::json!({ "url": "https://example.com/page" }); + assert_eq!( + format_mcp_initial_title("open_url_in_browser", &input), + "Run MCP tool `open_url_in_browser` https://example.com/page" + ); + } + + #[test] + fn test_format_mcp_initial_title_no_args() { + let input = serde_json::json!({}); + assert_eq!( + format_mcp_initial_title("cleanup", &input), + "Run MCP tool `cleanup`" + ); + } + + #[test] + fn test_format_mcp_initial_title_null_input() { + assert_eq!( + format_mcp_initial_title("cleanup", &serde_json::Value::Null), + "Run MCP tool `cleanup`" + ); + } + + #[test] + fn test_format_mcp_initial_title_multiple_fields_falls_back() { + let input = serde_json::json!({ "x": "a", "y": "b" }); + assert_eq!( + format_mcp_initial_title("do_thing", &input), + "Run MCP tool `do_thing`" + ); + } + + #[test] + fn test_format_mcp_initial_title_non_string_field_falls_back() { + let input = serde_json::json!({ "count": 42 }); + assert_eq!( + format_mcp_initial_title("tick", &input), + "Run MCP tool `tick`" + ); + } + + #[test] + fn test_format_mcp_initial_title_truncates_long_values() { + let long = "x".repeat(MAX_INLINE_ARG_LEN + 50); + let input = serde_json::json!({ "q": long }); + let title = format_mcp_initial_title("search", &input); + assert!( + title.ends_with('…'), + "expected truncation ellipsis, got: {title}" + ); + // Prefix + backticked name + space + MAX chars + ellipsis — no full 170-char value. + assert!(title.chars().count() < MAX_INLINE_ARG_LEN + 50); + } + + #[test] + fn test_format_mcp_initial_title_escapes_markdown_in_value() { + let input = serde_json::json!({ "q": "**bold** _italic_" }); + let title = format_mcp_initial_title("search", &input); + // Asterisks and underscores must be escaped so the header renders literally. + assert!(title.contains("\\*"), "expected \\*, got: {title}"); + assert!(title.contains("\\_"), "expected \\_, got: {title}"); + } + + #[test] + fn test_truncate_chars_boundary() { + assert_eq!(truncate_chars("abc", 3), "abc"); + assert_eq!(truncate_chars("abcd", 3), "abc…"); + } + + #[test] + fn test_truncate_chars_handles_multibyte() { + // "café" is 4 chars but 5 bytes — byte-based truncation would panic. + assert_eq!(truncate_chars("café", 4), "café"); + assert_eq!(truncate_chars("café", 3), "caf…"); + } + + #[test] + fn test_single_string_arg_ignores_empty_string() { + // An empty string is still a string — we inline it rather than fall back, + // which lets callers tell "the server sent an empty arg" apart from + // "no args at all". + let input = serde_json::json!({ "q": "" }); + assert_eq!(single_string_arg(&input), Some("")); + } } diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 308e7b9145805f..22918c43154857 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -5,7 +5,7 @@ use super::tool_permissions::{ use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -17,12 +17,13 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_path, }; -use std::path::Path; +use std::path::{Path, PathBuf}; -/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. +/// Creates a new directory at the specified path, and all necessary parent directories. Returns confirmation that the directory was created. /// -/// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project. -/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. +/// Use this whenever you need to create new directories. Paths inside the project are created directly. +/// +/// This tool can also create a directory **outside** the project. When agent terminal commands are sandboxed, doing so grants those commands write access to exactly that new directory — so, rather than requesting write access to a broad existing parent (e.g. your home directory) just to create something inside it, create the specific directory here first and then write into it. The only other supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CreateDirectoryToolInput { /// The path of the new directory. @@ -40,6 +41,13 @@ pub struct CreateDirectoryToolInput { /// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. /// pub path: String, + + /// Justification for creating a directory **outside** the project, shown to + /// the user (attributed to you) in the approval prompt that grants sandboxed + /// terminal commands write access to it. Required only for out-of-project + /// paths; ignored for paths inside the project or the global skills dir. + #[serde(default)] + pub reason: Option, } pub struct CreateDirectoryTool { @@ -83,6 +91,28 @@ impl AgentTool for CreateDirectoryTool { let project = self.project.clone(); cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; + + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); + + // Resolve where this directory lives. The global agent-skills dir is a + // special case allowed outside the project; anything else outside the + // project is handled as a narrow sandbox write grant below. + let global_skill_directory = + resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await; + let in_project = project.read_with(cx, |project, cx| { + project.find_project_path(&input.path, cx).is_some() + }); + + // A path outside the project (and not the global skills dir) can only + // be created as a narrow sandbox write grant: create the directory and + // grant sandboxed terminal commands write access to exactly it. The + // sandbox approval prompt — which shows the real, canonicalized target + // — fully replaces the normal permission and symlink-escape prompts + // here. + if global_skill_directory.is_none() && !in_project { + return create_out_of_project_directory(&project, &input, &event_stream, cx).await; + } + let decision = cx.update(|cx| { decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx)) }); @@ -93,7 +123,6 @@ impl AgentTool for CreateDirectoryTool { let destination_path: Arc = input.path.as_str().into(); - let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; let symlink_escape_target = project.read_with(cx, |project, cx| { @@ -149,9 +178,7 @@ impl AgentTool for CreateDirectoryTool { authorize.await.map_err(|e| e.to_string())?; } - if let Some(global_skill_directory) = - resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await - { + if let Some(global_skill_directory) = global_skill_directory { futures::select! { result = fs.create_dir(&global_skill_directory).fuse() => { result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?; @@ -185,6 +212,100 @@ impl AgentTool for CreateDirectoryTool { } } +/// Create a directory that lives **outside** the project by granting sandboxed +/// terminal commands write access to exactly it. +/// +/// The directory is created (Linux: eagerly, pinning the inode; macOS: after +/// approval) and the user is shown the real, canonicalized target in the sandbox +/// approval prompt — which is what defends against a concurrent symlink swap: the +/// grant is always against the inode/path the user actually saw. On denial, only +/// the directories we created are removed. +async fn create_out_of_project_directory( + project: &Entity, + input: &CreateDirectoryToolInput, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result { + // Narrowing a grant to a brand-new directory only makes sense when the + // project's terminal commands are sandboxed, and only on platforms that can + // grant a not-yet-existing directory. Otherwise keep the historical + // "outside the project" rejection. + let sandboxing = project.read_with(cx, |project, cx| { + crate::sandboxing::sandboxing_enabled_for_project(project, cx) + }); + let platform_supported = cfg!(any(target_os = "linux", target_os = "macos")); + if !sandboxing || !platform_supported { + return Err("Path to create was outside the project".to_string()); + } + + let Some(reason) = input + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + else { + return Err( + "Creating a directory outside the project grants sandboxed terminal commands write \ + access to it, so a `reason` is required: briefly justify why the directory is needed, \ + then try again." + .to_string(), + ); + }; + let reason = reason.to_string(); + + let absolute = resolve_absolute_path(project, &input.path, cx) + .ok_or_else(|| format!("Couldn't resolve `{}` to an absolute path.", input.path))?; + + let prepared = cx + .background_spawn(async move { sandbox::GrantableWriteDir::prepare(&absolute) }) + .await + .map_err(|error| format!("Creating directory {}: {error}", input.path))?; + + let canonical = prepared.canonical_path().to_path_buf(); + let request = crate::sandboxing::SandboxRequest { + write_paths: vec![canonical.clone()], + ..Default::default() + }; + + let approve = cx.update(|cx| event_stream.authorize_sandbox(request, reason, cx)); + match approve.await { + Ok(()) => { + let display = canonical.display().to_string(); + cx.background_spawn(async move { prepared.finalize() }) + .await + .map_err(|error| format!("Creating directory {display}: {error}"))?; + Ok(format!("Created directory {display}")) + } + Err(error) => { + // Roll back exactly what we created; leave the user no litter. + cx.background_spawn(async move { prepared.discard() }).await; + Err(format!("Create directory cancelled: {error}")) + } + } +} + +/// Resolve a model-provided path to an absolute, lexically-normalized path. +/// Relative paths are joined onto the first worktree root. +fn resolve_absolute_path( + project: &Entity, + raw: &str, + cx: &mut AsyncApp, +) -> Option { + let path = Path::new(raw); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + let base = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + })?; + base.join(path) + }; + util::paths::normalize_lexically(&absolute).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -231,7 +352,10 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - ToolInput::resolved(CreateDirectoryToolInput { path: input_path }), + ToolInput::resolved(CreateDirectoryToolInput { + path: input_path, + reason: None, + }), event_stream, cx, ) @@ -286,6 +410,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: outside_path.to_string_lossy().into_owned(), + reason: None, }), event_stream, cx, @@ -342,6 +467,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -404,6 +530,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -463,6 +590,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -545,6 +673,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -561,4 +690,107 @@ mod tests { "Deny policy should not emit symlink authorization prompt", ); } + + /// Out-of-project creation goes through the sandbox write-grant prompt and, + /// on approval, creates the *specific* new directory (not its broad parent). + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[gpui::test] + async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + // The sandbox create path operates on the *real* filesystem, so use a + // real directory outside the (fake) project. + let scratch = tempfile::tempdir().unwrap(); + let target = scratch.path().join("new_grant_dir"); + assert!(!target.exists()); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let path_input = target.to_string_lossy().into_owned(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { + path: path_input, + reason: Some("scratch space for the build".into()), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta) + .expect("out-of-project create should request a sandbox write grant"); + // The grant is for exactly the new directory, not its parent. + assert_eq!( + details.write_paths, + vec![scratch.path().canonicalize().unwrap().join("new_grant_dir")] + ); + + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), + acp::PermissionOptionKind::AllowAlways, + )) + .unwrap(); + + let result = task.await; + assert!(result.is_ok(), "expected success, got {result:?}"); + assert!( + target.is_dir(), + "the new directory should have been created" + ); + } + + /// Denying the grant removes the directory we eagerly created, leaving no + /// trace on the filesystem. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[gpui::test] + async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let scratch = tempfile::tempdir().unwrap(); + let target = scratch.path().join("denied_dir"); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let path_input = target.to_string_lossy().into_owned(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { + path: path_input, + reason: Some("scratch space".into()), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), + acp::PermissionOptionKind::RejectOnce, + )) + .unwrap(); + + let result = task.await; + assert!(result.is_err(), "denied create should fail"); + assert!( + !target.exists(), + "denied create should leave no directory behind" + ); + } } diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index 7a23ac38d92ddc..0f837e5144e318 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -247,9 +247,7 @@ impl AgentTool for DeletePathTool { } let deletion_task = project - .update(cx, |project, cx| { - project.delete_file(project_path, false, cx) - }) + .update(cx, |project, cx| project.delete_file(project_path, cx)) .ok_or_else(|| { format!("Couldn't delete {path} because that path isn't in this project.") })?; diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 8cf5610531d9f2..4734dd6b36c338 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -324,6 +324,77 @@ mod tests { assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); } + #[gpui::test] + async fn test_streaming_edit_first_line_missing_indent(cx: &mut TestAppContext) { + // Reproduces https://github.com/zed-industries/zed/issues/60302: the + // first line of the multi-line `old_text` omits its leading + // indentation while subsequent lines include theirs, so the indent + // delta computed from the first line must not be applied to the + // following lines. `old_text` also omits the `self.extra` line, so + // the query lines don't correspond one-to-one to the matched buffer + // rows and the indent pairing must follow the fuzzy match's + // alignment instead of assuming equal line counts. + let content = concat!( + "class Outer:\n", + " def method(self):\n", + " self.kept = \"unchanged\"\n", + " self.target_a = \"before\"\n", + " self.extra = \"row\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"\n", + " self.kept_2 = \"unchanged\"\n", + ); + let (edit_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.py": content})).await; + let result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/file.py".into(), + edits: vec![Edit { + old_text: concat!( + "self.target_a = \"before\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"", + ) + .into(), + new_text: concat!( + "self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + " self.target_c = \"after\"\n", + " self.target_d = \"after\"", + ) + .into(), + }], + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + // The matched range includes the `self.extra` row, so it is replaced + // along with the rest of the match. + assert_eq!( + new_text, + concat!( + "class Outer:\n", + " def method(self):\n", + " self.kept = \"unchanged\"\n", + " self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + " self.target_c = \"after\"\n", + " self.target_d = \"after\"\n", + " self.kept_2 = \"unchanged\"\n", + ) + ); + } + #[gpui::test] async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) { let (edit_tool, _project, _action_log, _fs, _thread) = setup_test( diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index b6f8c0f1cfcd13..bf5308e90daa73 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -16,7 +16,7 @@ use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; -use reindent::{Reindenter, compute_indent_delta}; +use reindent::{Reindenter, compute_indent_delta, compute_rest_indent_delta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::ops::Range; @@ -527,15 +527,34 @@ impl EditPipeline { ); let buffer_indent = snapshot.line_indent_for_row(line); + let query_lines = matcher.query_lines(); let query_indent = text::LineIndent::from_iter( - matcher - .query_lines() + query_lines .first() .map(|s| s.as_str()) .unwrap_or("") .chars(), ); - let indent_delta = compute_indent_delta(buffer_indent, query_indent); + let first_line_delta = compute_indent_delta(buffer_indent, query_indent); + + // Query row 0 is excluded: its delta is `first_line_delta`, + // which intentionally differs when the model stripped the + // first line's indentation. + let rest_delta = compute_rest_indent_delta( + first_line_delta, + matcher + .line_pairs(&range) + .unwrap_or(&[]) + .iter() + .filter(|(query_row, _)| *query_row != 0) + .filter_map(|(query_row, buffer_row)| { + let query_line = query_lines.get(*query_row as usize)?; + Some(( + snapshot.line_indent_for_row(*buffer_row), + text::LineIndent::from_iter(query_line.chars()), + )) + }), + ); let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::(); @@ -551,7 +570,7 @@ impl EditPipeline { self.current_edit = Some(EditPipelineEntry::StreamingNewText { streaming_diff: StreamingDiff::new(old_text_in_buffer), edit_cursor: range.start, - reindenter: Reindenter::new(indent_delta), + reindenter: Reindenter::with_deltas(first_line_delta, rest_delta), original_snapshot: text_snapshot, }); @@ -1226,11 +1245,11 @@ fn resolve_path( let file_name = path .file_name() .and_then(|file_name| file_name.to_str()) - .and_then(|file_name| RelPath::unix(file_name).ok()) + .and_then(|file_name| RelPath::from_unix_str(file_name).ok()) .ok_or_else(|| "Can't create file: invalid filename".to_string())?; let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: parent.path.join(file_name), + path: parent.path.join(file_name).into(), ..parent }); diff --git a/crates/agent/src/tools/edit_session/reindent.rs b/crates/agent/src/tools/edit_session/reindent.rs index 7f08749e475f6a..ed4066e30d26a6 100644 --- a/crates/agent/src/tools/edit_session/reindent.rs +++ b/crates/agent/src/tools/edit_session/reindent.rs @@ -1,7 +1,7 @@ use language::LineIndent; use std::{cmp, iter}; -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum IndentDelta { Spaces(isize), Tabs(isize), @@ -31,20 +31,60 @@ pub fn compute_indent_delta(buffer_indent: LineIndent, query_indent: LineIndent) } } +/// Computes the indent delta for the lines after the first, given per-line +/// `(buffer, query)` indents for those lines. +/// +/// When the remaining lines agree on a consistent delta, that delta is +/// returned even if it differs from `first_line_delta`. This handles queries +/// where only the first line's indentation was stripped. When the remaining +/// lines are inconsistent (or all blank), falls back to `first_line_delta`, +/// preserving the uniform re-indentation behavior. +pub fn compute_rest_indent_delta( + first_line_delta: IndentDelta, + indent_pairs: impl IntoIterator, +) -> IndentDelta { + let mut rest_delta = None; + for (buffer_indent, query_indent) in indent_pairs { + if buffer_indent.line_blank || query_indent.line_blank { + continue; + } + let delta = compute_indent_delta(buffer_indent, query_indent); + match rest_delta { + None => rest_delta = Some(delta), + Some(existing) if existing == delta => {} + Some(_) => return first_line_delta, + } + } + rest_delta.unwrap_or(first_line_delta) +} + /// Synchronous re-indentation adapter. Buffers incomplete lines and applies /// an `IndentDelta` to each line's leading whitespace before emitting it. +/// +/// Models sometimes omit the leading indentation only on the first line of +/// `old_text`/`new_text` (e.g. when copying from mid-line context), so the +/// first line and the remaining lines can require different deltas. pub struct Reindenter { - delta: IndentDelta, + first_line_delta: IndentDelta, + rest_delta: IndentDelta, buffer: String, in_leading_whitespace: bool, + on_first_line: bool, } impl Reindenter { - pub fn new(delta: IndentDelta) -> Self { + #[cfg(test)] + fn uniform(delta: IndentDelta) -> Self { + Self::with_deltas(delta, delta) + } + + pub fn with_deltas(first_line_delta: IndentDelta, rest_delta: IndentDelta) -> Self { Self { - delta, + first_line_delta, + rest_delta, buffer: String::new(), in_leading_whitespace: true, + on_first_line: true, } } @@ -70,14 +110,19 @@ impl Reindenter { None => (self.buffer.len(), true), }; let line = &self.buffer[start_ix..line_end]; + let delta = if self.on_first_line { + self.first_line_delta + } else { + self.rest_delta + }; if self.in_leading_whitespace { - if let Some(non_whitespace_ix) = line.find(|c| self.delta.character() != c) { + if let Some(non_whitespace_ix) = line.find(|c| delta.character() != c) { // We found a non-whitespace character, adjust indentation // based on the delta. let new_indent_len = - cmp::max(0, non_whitespace_ix as isize + self.delta.len()) as usize; - indented.extend(iter::repeat(self.delta.character()).take(new_indent_len)); + cmp::max(0, non_whitespace_ix as isize + delta.len()) as usize; + indented.extend(iter::repeat(delta.character()).take(new_indent_len)); indented.push_str(&line[non_whitespace_ix..]); self.in_leading_whitespace = false; } else if is_pending_line && !is_final { @@ -97,6 +142,7 @@ impl Reindenter { break; } else { self.in_leading_whitespace = true; + self.on_first_line = false; indented.push('\n'); start_ix = line_end + 1; } @@ -116,7 +162,7 @@ mod tests { #[test] fn test_indent_single_chunk() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); let out = r.push(" abc\n def\n ghi"); // All three lines are emitted: "ghi" starts with spaces but // contains non-whitespace, so it's processed immediately. @@ -127,7 +173,7 @@ mod tests { #[test] fn test_outdent_tabs() { - let mut r = Reindenter::new(IndentDelta::Tabs(-2)); + let mut r = Reindenter::uniform(IndentDelta::Tabs(-2)); let out = r.push("\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi"); assert_eq!(out, "\t\tabc\ndef\n\t\t\t\tghi"); let out = r.finish(); @@ -136,7 +182,7 @@ mod tests { #[test] fn test_incremental_chunks() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); // Feed " ab" — the `a` is non-whitespace, so the line is // processed immediately even without a trailing newline. let out = r.push(" ab"); @@ -151,7 +197,7 @@ mod tests { #[test] fn test_zero_delta() { - let mut r = Reindenter::new(IndentDelta::Spaces(0)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(0)); let out = r.push(" hello\n world\n"); assert_eq!(out, " hello\n world\n"); let out = r.finish(); @@ -160,7 +206,7 @@ mod tests { #[test] fn test_clamp_negative_indent() { - let mut r = Reindenter::new(IndentDelta::Spaces(-10)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(-10)); let out = r.push(" abc\n"); // max(0, 2 - 10) = 0, so no leading spaces. assert_eq!(out, "abc\n"); @@ -170,7 +216,7 @@ mod tests { #[test] fn test_whitespace_only_lines() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); let out = r.push(" \n code\n"); // First line is all whitespace — emitted verbatim. Second line is indented. assert_eq!(out, " \n code\n"); @@ -178,6 +224,95 @@ mod tests { assert_eq!(out, ""); } + #[test] + fn test_distinct_first_line_delta() { + // First line's indentation was stripped in the query (delta +8), + // while the remaining lines are already correct (delta 0). Chunks + // split mid-line and mid-indentation to exercise the streaming path, + // and the blank line is passed through verbatim. + let mut r = Reindenter::with_deltas(IndentDelta::Spaces(8), IndentDelta::Spaces(0)); + let mut out = r.push("self.target_a = "); + out.push_str(&r.push("\"after\"\n ")); + out.push_str(&r.push(" self.target_b = \"after\"\n")); + out.push_str(&r.push("\n self.target_c = \"after\"")); + out.push_str(&r.finish()); + assert_eq!( + out, + concat!( + " self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + "\n", + " self.target_c = \"after\"", + ) + ); + } + + fn line_indent(text: &str) -> LineIndent { + LineIndent::from_iter(text.chars()) + } + + #[test] + fn test_compute_rest_indent_delta() { + let first_line_delta = IndentDelta::Spaces(8); + + // Remaining lines that agree on a delta override the first-line + // delta, and blank lines are skipped when forming the consensus. + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(""), line_indent("")), + (line_indent(" c"), line_indent(" c")), + ], + ), + IndentDelta::Spaces(0) + ); + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(" "), line_indent("")), + (line_indent(" c"), line_indent(" c")), + ], + ), + IndentDelta::Spaces(4) + ); + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![(line_indent("\t\tb"), line_indent("\tb"))], + ), + IndentDelta::Tabs(1) + ); + + // Inconsistent remaining lines fall back to the first-line delta... + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(" c"), line_indent(" c")), + ], + ), + first_line_delta + ); + + // ...and so do all-blank and empty pairings. + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![(line_indent(" "), line_indent(""))], + ), + first_line_delta + ); + assert_eq!( + compute_rest_indent_delta(first_line_delta, vec![]), + first_line_delta + ); + } + #[test] fn test_compute_indent_delta_spaces() { let buffer = LineIndent { diff --git a/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs index e6a56099a29321..3515275f70a3c6 100644 --- a/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs @@ -12,10 +12,18 @@ pub struct StreamingFuzzyMatcher { query_lines: Vec, line_hint: Option, incomplete_line: String, - matches: Vec>, + matches: Vec, matrix: SearchMatrix, } +/// A match candidate: the matched byte range plus the 0-based +/// `(query_row, buffer_row)` line pairs the search aligned to produce it. +#[derive(Clone, Debug)] +struct SearchMatch { + range: Range, + line_pairs: Vec<(u32, u32)>, +} + impl StreamingFuzzyMatcher { pub fn new(snapshot: TextBufferSnapshot) -> Self { let buffer_line_count = snapshot.max_point().row as usize + 1; @@ -34,6 +42,23 @@ impl StreamingFuzzyMatcher { &self.query_lines } + /// Returns the 0-based `(query_row, buffer_row)` line pairs that the + /// search aligned for the match with the given range. Lines that were + /// skipped on either side of the alignment are absent. + pub fn line_pairs(&self, range: &Range) -> Option<&[(u32, u32)]> { + self.matches + .iter() + .find(|search_match| search_match.range == *range) + .map(|search_match| search_match.line_pairs.as_slice()) + } + + fn match_ranges(&self) -> Vec> { + self.matches + .iter() + .map(|search_match| search_match.range.clone()) + .collect() + } + /// Push a new chunk of text and get the best match found so far. /// /// This method accumulates text chunks and processes complete lines. @@ -62,7 +87,11 @@ impl StreamingFuzzyMatcher { } let best_match = self.select_best_match(); - best_match.or_else(|| self.matches.first().cloned()) + best_match.or_else(|| { + self.matches + .first() + .map(|search_match| search_match.range.clone()) + }) } /// Finish processing and return the final best match(es). @@ -72,26 +101,35 @@ 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 let [only_match] = self.matches.as_mut_slice() { + let range = &mut only_match.range; 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(); + // Record the line and its alignment so that `query_lines` + // and `line_pairs` stay in sync with the lines covered by + // the returned range. + let extended_row = self.snapshot.offset_to_point(range.end).row; + self.query_lines + .push(std::mem::take(&mut self.incomplete_line)); + only_match + .line_pairs + .push(((self.query_lines.len() - 1) as u32, extended_row)); + return self.match_ranges(); } } - self.query_lines.push(self.incomplete_line.clone()); - self.incomplete_line.clear(); + self.query_lines + .push(std::mem::take(&mut self.incomplete_line)); self.matches = self.resolve_location_fuzzy(); } - self.matches.clone() + self.match_ranges() } - fn resolve_location_fuzzy(&mut self) -> Vec> { + fn resolve_location_fuzzy(&mut self) -> Vec { let new_query_line_count = self.query_lines.len(); let old_query_line_count = self.matrix.rows.saturating_sub(1); if new_query_line_count == old_query_line_count { @@ -167,7 +205,7 @@ impl StreamingFuzzyMatcher { // Find ranges for the matches let mut valid_matches = Vec::new(); for &buffer_row_end in &matches_with_best_cost { - let mut matched_lines = 0; + let mut line_pairs = Vec::new(); let mut query_row = new_query_line_count; let mut buffer_row_start = buffer_row_end; while query_row > 0 && buffer_row_start > 0 { @@ -176,7 +214,7 @@ impl StreamingFuzzyMatcher { SearchDirection::Diagonal => { query_row -= 1; buffer_row_start -= 1; - matched_lines += 1; + line_pairs.push((query_row as u32, buffer_row_start)); } SearchDirection::Up => { query_row -= 1; @@ -186,9 +224,10 @@ impl StreamingFuzzyMatcher { } } } + line_pairs.reverse(); let matched_buffer_row_count = buffer_row_end - buffer_row_start; - let matched_ratio = matched_lines as f32 + let matched_ratio = line_pairs.len() as f32 / (matched_buffer_row_count as f32).max(new_query_line_count as f32); if matched_ratio >= 0.8 { let buffer_start_ix = self @@ -198,11 +237,14 @@ impl StreamingFuzzyMatcher { buffer_row_end - 1, self.snapshot.line_len(buffer_row_end - 1), )); - valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix)); + valid_matches.push(SearchMatch { + range: buffer_start_ix..buffer_end_ix, + line_pairs, + }); } } - valid_matches.into_iter().map(|(_, range)| range).collect() + valid_matches } /// Return the best match with starting position close enough to line_hint. @@ -216,8 +258,8 @@ impl StreamingFuzzyMatcher { return None; } - if self.matches.len() == 1 { - return self.matches.first().cloned(); + if let [only_match] = self.matches.as_slice() { + return Some(only_match.range.clone()); } let Some(line_hint) = self.line_hint else { @@ -228,14 +270,14 @@ impl StreamingFuzzyMatcher { let mut best_match = None; let mut best_distance = u32::MAX; - for range in &self.matches { - let start_point = self.snapshot.offset_to_point(range.start); + for search_match in &self.matches { + let start_point = self.snapshot.offset_to_point(search_match.range.start); let start_line = start_point.row; let distance = start_line.abs_diff(line_hint); if distance <= LINE_HINT_TOLERANCE && distance < best_distance { best_distance = distance; - best_match = Some(range.clone()); + best_match = Some(search_match.range.clone()); } } @@ -831,6 +873,79 @@ mod tests { } } + #[test] + fn test_line_pairs_skip_unmatched_buffer_line() { + let text = indoc! {r#" + class Outer: + def method(self): + self.kept = "unchanged" + self.target_a = "before" + self.extra = "row" + self.target_b = "before" + self.target_c = "before" + self.target_d = "before" + self.kept_2 = "unchanged" + "#}; + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + // The query omits the `self.extra` row that sits between the matched + // buffer lines. + matcher.push( + concat!( + " self.target_a = \"before\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"\n", + ), + None, + ); + let matches = matcher.finish(); + + assert_eq!(matches.len(), 1); + assert_eq!( + matcher.line_pairs(&matches[0]), + Some(&[(0, 3), (1, 5), (2, 6), (3, 7)][..]) + ); + } + + #[test] + fn test_line_pairs_include_extended_incomplete_line() { + 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 mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + // The last query line is incomplete and gets appended to the match by + // `finish` via verbatim comparison rather than the fuzzy search. + matcher.push("}\n\n\n\nfn render_search", None); + let matches = matcher.finish(); + + assert_eq!(matches.len(), 1); + assert_eq!( + matcher.line_pairs(&matches[0]), + Some(&[(0, 2), (1, 3), (2, 4), (3, 5), (4, 6)][..]) + ); + assert_eq!(matcher.query_lines().len(), 5); + } + fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec { let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index eb690cdcdf08b3..4a8537805db40b 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -503,7 +503,9 @@ impl EditToolTest { if tool_use.is_input_complete && tool_use.name.as_ref() == EditFileTool::NAME => { - let input: EditFileToolInput = serde_json::from_value(tool_use.input) + let input: EditFileToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as EditFileToolInput")?; return Ok(input); } @@ -607,7 +609,9 @@ fn tool_use( id: LanguageModelToolUseId::from(id.into()), name: name.into(), raw_input: serde_json::to_string_pretty(&input).unwrap(), - input: serde_json::to_value(input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(input).unwrap(), + ), is_input_complete: true, thought_signature: None, }) diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index d5c3ac1ba87be1..31b35c0e539542 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -327,7 +327,9 @@ async fn extract_tool_use( Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME => { - let input: TerminalToolInput = serde_json::from_value(tool_use.input) + let input: TerminalToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as TerminalToolInput")?; return Ok(input); } diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index 3fce2b04047728..c0fb1de7de7b88 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -323,7 +323,9 @@ impl WriteToolTest { if tool_use.is_input_complete && tool_use.name.as_ref() == WriteFileTool::NAME => { - let input: WriteFileToolInput = serde_json::from_value(tool_use.input) + let input: WriteFileToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as WriteFileToolInput")?; return Ok(input); } @@ -410,7 +412,9 @@ fn tool_use( id: LanguageModelToolUseId::from(id.into()), name: name.into(), raw_input: serde_json::to_string_pretty(&input).unwrap(), - input: serde_json::to_value(input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(input).unwrap(), + ), is_input_complete: true, thought_signature: None, }) diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index 7bc93740af2d3c..e08d7dbc59ebf1 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use ui::SharedString; use util::markdown::{MarkdownEscaped, MarkdownInlineCode}; +use crate::sandboxing::{NetworkRequest, SandboxRequest}; use crate::{AgentTool, ToolCallEventStream, ToolInput}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] @@ -22,7 +23,41 @@ enum ContentType { Json, } +/// The maximum number of HTTP redirects the fetch tool will follow. Each hop is +/// re-authorized against the shared network grants before being followed. +const MAX_REDIRECTS: usize = 20; + +/// The outcome of a single (non-redirect-following) HTTP request. +enum FetchStep { + /// The server responded with a redirect to this absolute URL. Its host must + /// be authorized before the redirect is followed. + Redirect(String), + /// A terminal response was received and converted to Markdown. + Complete(String), +} + +/// Prepends `https://` when the URL has no explicit HTTP(S) scheme, matching the +/// behavior the fetch tool has always had for user/model-supplied URLs. +fn normalize_url(url: &str) -> Cow<'_, str> { + if !url.starts_with("https://") && !url.starts_with("http://") { + Cow::Owned(format!("https://{url}")) + } else { + Cow::Borrowed(url) + } +} + /// Fetches a URL and returns the content as Markdown. +/// +/// This tool is not run inside the terminal OS sandbox, but it still refuses to +/// reach any host that hasn't been granted network access. It shares the same +/// per-host grants as the `terminal` tool: approving a host for one authorizes +/// it for the other, whether the grant is for this thread or saved permanently. +/// HTTP redirects are followed one hop at a time, and each hop's host must be +/// granted the same way, so a granted host can't redirect the request to a host +/// that hasn't been approved. +/// When unsandboxed access has been granted, these restrictions are lifted +/// entirely, matching the terminal, which is also how loopback and IP-literal +/// hosts (which can't be granted individually) become reachable. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct FetchToolInput { /// The URL to fetch. @@ -38,14 +73,35 @@ impl FetchTool { Self { http_client } } - async fn build_message(http_client: Arc, url: &str) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - Cow::Owned(format!("https://{url}")) - } else { - Cow::Borrowed(url) - }; + /// Performs a single HTTP GET *without* following redirects, so the tool can + /// re-authorize each hop against the shared network grants before following + /// it. Returns the redirect target when the server responds with a 3xx, or + /// the final content converted to Markdown otherwise. + async fn fetch_step(http_client: Arc, url: &str) -> Result { + let normalized = normalize_url(url); + + let mut response = http_client + .get(&normalized, AsyncBody::default(), false) + .await?; - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + let status = response.status(); + if status.is_redirection() { + let location = response + .headers() + .get("location") + .context("redirect response is missing a Location header")? + .to_str() + .context("redirect response has an invalid Location header")?; + let target = url::Url::parse(&normalized) + .with_context(|| format!("could not parse URL {normalized:?}"))? + .join(location) + .with_context(|| format!("invalid redirect target {location:?}"))?; + anyhow::ensure!( + matches!(target.scheme(), "http" | "https"), + "refusing to follow redirect to non-HTTP(S) URL {target}" + ); + return Ok(FetchStep::Redirect(target.to_string())); + } let mut body = Vec::new(); response @@ -54,12 +110,9 @@ impl FetchTool { .await .context("error reading response body")?; - if response.status().is_client_error() { + if status.is_client_error() { let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); + bail!("status error {}, response: {text:?}", status.as_u16()); } let Some(content_type) = response.headers().get("content-type") else { @@ -77,7 +130,7 @@ impl FetchTool { ContentType::Html }; - match content_type { + let text = match content_type { ContentType::Html => { let mut handlers: Vec = vec![ Rc::new(RefCell::new(markdown::WebpageChromeRemover)), @@ -87,7 +140,7 @@ impl FetchTool { Rc::new(RefCell::new(markdown::TableHandler::new())), Rc::new(RefCell::new(markdown::StyledTextHandler)), ]; - if url.contains("wikipedia.org") { + if normalized.contains("wikipedia.org") { use html_to_markdown::structure::wikipedia; handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); @@ -99,21 +152,65 @@ impl FetchTool { handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); } - convert_html_to_markdown(&body[..], &mut handlers) + convert_html_to_markdown(&body[..], &mut handlers)? } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Plaintext => std::str::from_utf8(&body)?.to_owned(), ContentType::Json => { let json: serde_json::Value = serde_json::from_slice(&body)?; - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) + format!("```json\n{}\n```", serde_json::to_string_pretty(&json)?) } - } + }; + + Ok(FetchStep::Complete(text)) } } +/// Resolve the host of `url` and confirm it doesn't point into loopback / +/// private / link-local space, applying the same forbidden-IP policy the +/// terminal sandbox's proxy uses. Returns an error (including "resolves only to +/// forbidden addresses") that aborts the fetch. +/// +/// DNS resolution blocks, so callers should run this off the foreground thread. +/// See the caller for why this is a gate rather than a full resolve-to-connect +/// pin. +fn verify_host_not_forbidden(url: &str) -> Result<()> { + let normalized = normalize_url(url); + let parsed = + url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?; + let host = parsed + .host_str() + .with_context(|| format!("URL {url:?} has no host to reach"))?; + // Default to the scheme's port when the URL omits one; resolution needs a + // port but the value doesn't affect which IPs a host resolves to. + let port = parsed + .port_or_known_default() + .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 }); + + http_proxy::PinnedHost::resolve(host, port).map(|_pinned| ())?; + Ok(()) +} + +/// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can +/// be matched against the shared network grants. Mirrors the scheme handling in +/// [`normalize_url`] (defaulting to `https://` when none is given). +fn host_pattern_for_url(url: &str) -> Result { + let normalized = normalize_url(url); + let parsed = + url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?; + let host = parsed + .host_str() + .with_context(|| format!("URL {url:?} has no host to authorize network access for"))?; + http_proxy::HostPattern::parse(host).map_err(|error| match error { + http_proxy::HostPatternError::IpLiteral(_) => anyhow::anyhow!( + "cannot fetch {host:?}: loopback and IP-literal hosts can't be granted network \ + access individually. They are only reachable once unsandboxed access has been \ + granted (for example, via a terminal command that requests it)." + ), + error => anyhow::anyhow!("cannot authorize network access to {host:?}: {error}"), + }) +} + impl AgentTool for FetchTool { type Input = FetchToolInput; type Output = String; @@ -124,6 +221,10 @@ impl AgentTool for FetchTool { acp::ToolKind::Fetch } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -145,6 +246,8 @@ impl AgentTool for FetchTool { cx.spawn(async move |cx| { let input: FetchToolInput = input.recv().await.map_err(|e| e.to_string())?; + // First, the standard tool-permission gate (honors the fetch tool's + // allow/deny/confirm rules). let authorize = cx.update(|cx| { let context = crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]); @@ -155,22 +258,104 @@ impl AgentTool for FetchTool { cx, ) }); + futures::select! { + result = authorize.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } + }; - let fetch_task = cx.background_spawn({ - let http_client = http_client.clone(); - let url = input.url.clone(); - async move { - authorize.await?; - Self::build_message(http_client, &url).await + // Then, unless unsandboxed access is already in effect, the per-host + // network grant shared with the terminal tool. If the host isn't + // already granted (for this thread or in saved settings) the user is + // shown the same escalation prompt the terminal uses; a denial + // aborts the fetch. This tool never runs inside the OS sandbox, so + // the grant is only consulted to decide whether the request may + // proceed. When unsandboxed access has been granted the terminal + // already runs without isolation, so we drop fetch's restrictions + // too — including reaching hosts that can't be granted individually + // (loopback and IP literals). + // + // Crucially, this authorization is applied to every redirect hop as + // well as the initial URL, so a granted host can't 30x-redirect the + // fetch to a host the user never approved. We disable the HTTP + // client's own redirect following and re-run the grant for each hop + // before requesting it. + let unsandboxed = cx.update(|cx| event_stream.unsandboxed_access_granted(cx)); + + let mut current_url = input.url.clone(); + let mut redirects = 0; + let text = loop { + if !unsandboxed { + let host = host_pattern_for_url(¤t_url).map_err(|e| e.to_string())?; + let authorize_host = cx.update(|cx| { + let request = SandboxRequest { + network: NetworkRequest::Hosts(vec![host]), + ..Default::default() + }; + event_stream.authorize_sandbox(request, String::new(), cx) + }); + futures::select! { + result = authorize_host.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } + }; + + // Authorizing the *hostname* is not enough: a granted host + // (or a redirect to one) whose DNS points into loopback / + // private / link-local space would otherwise let the model + // reach the local machine or LAN (SSRF / DNS rebinding). + // Resolve and vet the host now, applying the same + // forbidden-IP policy the terminal sandbox's proxy enforces. + // + // NOTE: this is a gate, not a full pin. `HttpClientWithUrl` + // resolves the hostname again when it connects, so a DNS + // answer that flips between this check and that connect could + // still slip through. Closing that residual window would + // require the HTTP client to connect to a pre-vetted IP + // (`PinnedHost::socket_addrs`) rather than re-resolving; + // until then this blocks the realistic case of a stably + // resolving host that points at forbidden space. + let verify_task = cx.background_spawn({ + let url = current_url.clone(); + async move { verify_host_not_forbidden(&url) } + }); + futures::select! { + result = verify_task.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } + }; } - }); - let text = futures::select! { - result = fetch_task.fuse() => result.map_err(|e| e.to_string())?, - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Fetch cancelled by user".to_string()); + let fetch_task = cx.background_spawn({ + let http_client = http_client.clone(); + let url = current_url.clone(); + async move { Self::fetch_step(http_client, &url).await } + }); + + let step = futures::select! { + result = fetch_task.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } + }; + + match step { + FetchStep::Complete(text) => break text, + FetchStep::Redirect(target) => { + redirects += 1; + if redirects > MAX_REDIRECTS { + return Err(format!( + "exceeded the maximum of {MAX_REDIRECTS} redirects" + )); + } + current_url = target; + } } }; + if text.trim().is_empty() { return Err("no textual content found".to_string()); } @@ -178,3 +363,45 @@ impl AgentTool for FetchTool { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + // These use IP-literal URLs, which "resolve" to themselves, so the SSRF gate + // is exercised without depending on real DNS. IP literals can't be *granted* + // network access (that's a separate, earlier check), but they can be the + // target a granted hostname redirects to or resolves into — which is exactly + // the case this gate defends. + + #[test] + fn verify_host_rejects_loopback_literal() { + let error = verify_host_not_forbidden("http://127.0.0.1/internal") + .expect_err("loopback must be refused"); + assert!( + error.to_string().contains("loopback"), + "error should explain the forbidden range, got: {error}" + ); + } + + #[test] + fn verify_host_rejects_private_and_metadata_literals() { + for url in [ + "http://10.0.0.5/", + "https://192.168.1.1/", + "http://169.254.169.254/latest/meta-data/", // cloud metadata + "http://[::1]/", + ] { + assert!( + verify_host_not_forbidden(url).is_err(), + "expected {url} to be refused as a forbidden destination" + ); + } + } + + #[test] + fn verify_host_allows_public_literal() { + verify_host_not_forbidden("https://93.184.215.14/") + .expect("a public address must be allowed through the gate"); + } +} diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 481f1433fbf7c8..eb08000d3a859e 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -1,4 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use acp_thread::MentionUri; use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use futures::FutureExt as _; @@ -155,10 +156,13 @@ impl AgentTool for FindPathTool { paginated_matches .iter() .map(|path| { + let uri = MentionUri::File { + abs_path: path.clone(), + }; acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::ResourceLink(acp::ResourceLink::new( path.to_string_lossy(), - format!("file://{}", path.display()), + uri.to_uri().to_string(), )), )) }) diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 748642b3cda6c1..5af92b2e451c0b 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -1,4 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use acp_thread::MentionUri; use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::{FutureExt as _, StreamExt}; @@ -327,10 +328,15 @@ impl AgentTool for GrepTool { output.push_str("\n```\n"); if let Some(abs_path) = &abs_path { + let uri = MentionUri::Selection { + abs_path: Some(abs_path.clone()), + line_range: range.start.row..=end_row, + column: None, + }; content.push(acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::ResourceLink(acp::ResourceLink::new( format!("{}#{}", path.display(), line_label), - format!("file://{}#{}", abs_path.display(), line_label), + uri.to_uri().to_string(), )), ))); locations.push( @@ -393,6 +399,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; + use std::path::PathBuf; use unindent::Unindent; use util::path; @@ -611,21 +618,30 @@ mod tests { .collect::>(); assert_eq!(links.len(), 2, "expected one resource link per match"); - let alpha_uri = format!("file://{}#L1", path!("/root/src/alpha.txt")); + let selection_uri = |abs_path: &str| { + MentionUri::Selection { + abs_path: Some(PathBuf::from(abs_path)), + line_range: 0..=0, + column: None, + } + .to_uri() + .to_string() + }; + + let alpha_uri = selection_uri(path!("/root/src/alpha.txt")); assert!( links.iter().any(|link| { - link.name.replace('\\', "/") == "root/src/alpha.txt#L1" - && link.uri.replace('\\', "/") == alpha_uri.replace('\\', "/") + link.name.replace('\\', "/") == "root/src/alpha.txt#L1" && link.uri == alpha_uri }), "missing clickable link for alpha.txt, got: {links:?}" ); - let beta_uri = format!("file://{}#L1", path!("/root/beta.txt")); + let beta_uri = selection_uri(path!("/root/beta.txt")); assert!( - links.iter().any(|link| { - link.name.replace('\\', "/") == "root/beta.txt#L1" - && link.uri.replace('\\', "/") == beta_uri.replace('\\', "/") - }), + links + .iter() + .any(|link| link.name.replace('\\', "/") == "root/beta.txt#L1" + && link.uri == beta_uri), "missing clickable link for beta.txt, got: {links:?}" ); diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 87d334689cbbcf..3e7954e5ba67d4 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -15,11 +15,11 @@ use std::{ #[cfg(any(target_os = "linux", target_os = "windows"))] use crate::SandboxFallbackDecision; -use crate::sandboxing::{NetworkRequest, sandboxing_enabled_for_project}; +use crate::sandboxing::{ + NetworkRequest, sandbox_git_dirs, sandbox_worktree_writable_paths, + sandboxing_enabled_for_project, +}; use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; -use sandbox_git_paths::{SandboxGitPathCandidates, sandbox_git_paths}; - -pub(crate) mod sandbox_git_paths; const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; @@ -44,13 +44,14 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this: /// /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). +/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`. /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// - /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. + /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, @@ -85,13 +86,14 @@ pub struct TerminalToolInput { /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this: /// /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). +/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`. /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct SandboxedTerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// - /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. + /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, @@ -129,9 +131,10 @@ pub struct SandboxedTerminalToolInput { /// Set to `true` only if the command needs outbound network access to /// hosts you can't enumerate up front. /// - /// This grants unrestricted outbound network access. Prefer `allow_hosts` - /// with specific hostnames whenever possible, so the user knows what's - /// being approved. Requesting it triggers a user approval prompt. + /// This grants unrestricted outbound network access. On platforms that + /// support host-specific grants, prefer `allow_hosts` with specific + /// hostnames whenever possible, so the user knows what's being approved. + /// Requesting it triggers a user approval prompt. #[serde(default)] pub allow_all_hosts: Option, /// Paths the command needs to write to outside the default-writable @@ -146,7 +149,8 @@ pub struct SandboxedTerminalToolInput { /// Provide absolute or worktree-relative paths; each /// directory grants write access to its whole subtree. Prefer this over /// `allow_fs_write_all` whenever you can enumerate the paths. Requesting - /// paths triggers a user approval prompt. + /// paths triggers a user approval prompt. Git metadata paths cannot be + /// requested and will never be made writable while sandboxed. #[cfg_attr( target_os = "linux", doc = "\nOn Linux, every path here must be a directory that already exists. \ @@ -160,25 +164,18 @@ pub struct SandboxedTerminalToolInput { /// enumerated up front. /// /// This is a broad escape hatch — prefer `fs_write_paths` whenever the - /// set of paths is known. Requesting it triggers a user approval prompt. + /// set of paths is known. Protected Git metadata remains read-only. + /// Requesting it triggers a user approval prompt. #[serde(default, alias = "allow_fs_write")] pub allow_fs_write_all: Option, - /// Set to `true` when the command needs access to protected Git metadata. - /// - /// By default sandboxed commands can't write to the `.git` directories of - /// opened worktrees and discovered repositories. On macOS, `.git` file - /// contents are also hidden while metadata stays visible; on Linux and - /// Windows/WSL, `.git` contents remain readable but are mounted read-only. - /// Set this for Git operations that need to write those paths (commit, - /// fetch, rebase, …). Requesting it triggers a user approval prompt. - #[serde(default)] - pub allow_git_access: Option, + /// Set to `true` only as a last resort, to run the command fully outside /// the sandbox. /// - /// First try the narrower options (`allow_hosts`, `fs_write_paths`, - /// `allow_fs_write_all`, `allow_git_access`); use this only when the command - /// needs behavior the sandbox can't grant on a per-permission basis. + /// First try the narrower options (`allow_hosts`, `fs_write_paths`, or + /// `allow_fs_write_all`); use this only when the command + /// needs behavior the sandbox can't grant on a per-permission basis, + /// including commands that must write Git metadata. /// Requesting it triggers a user approval prompt. #[cfg_attr( target_os = "windows", @@ -192,8 +189,8 @@ pub struct SandboxedTerminalToolInput { #[serde(default)] pub unsandboxed: Option, /// A short justification for why this command needs the sandbox - /// permission(s) it requests (`allow_network`, `fs_write_paths`, - /// `allow_fs_write_all`, or `unsandboxed`). + /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`, + /// `fs_write_paths`, `allow_fs_write_all`, or `unsandboxed`). /// /// Required whenever you request any of those permissions; omit it for /// ordinary commands that request none. Write it in your own voice — it @@ -209,7 +206,6 @@ struct TerminalSandboxInput { allow_all_hosts: Option, fs_write_paths: Vec, allow_fs_write_all: Option, - allow_git_access: Option, unsandboxed: Option, reason: Option, } @@ -252,7 +248,6 @@ impl From for TerminalToolRequest { allow_all_hosts: input.allow_all_hosts, fs_write_paths: input.fs_write_paths, allow_fs_write_all: input.allow_fs_write_all, - allow_git_access: input.allow_git_access, unsandboxed: input.unsandboxed, reason: input.reason, }), @@ -298,6 +293,10 @@ impl AgentTool for TerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -336,6 +335,10 @@ impl AgentTool for SandboxedTerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -372,6 +375,37 @@ fn terminal_initial_title(input: Result) -> SharedStr } } +/// Windows only: resolve the `(release channel, version)` of the Linux `zed` to +/// provision inside WSL as the sandbox helper. Dev (source) builds have no +/// matching release, so they pull the latest nightly. Nightly builds also track +/// `latest`: nightly assets are keyed by their full build metadata +/// (`X.Y.Z+nightly..`), which `AppVersion` strips, so a bare `X.Y.Z` +/// never resolves on the nightly host. Preview and stable pin their exact +/// running version (stripped of pre-release/build metadata, which the release +/// API doesn't key on). +#[cfg(target_os = "windows")] +fn wsl_zed_release(cx: &App) -> Option<(String, String)> { + use release_channel::{AppVersion, ReleaseChannel}; + match *release_channel::RELEASE_CHANNEL { + ReleaseChannel::Dev | ReleaseChannel::Nightly => { + Some(("nightly".to_string(), "latest".to_string())) + } + channel => { + let version = AppVersion::global(cx); + Some(( + channel.dev_name().to_string(), + format!("{}.{}.{}", version.major, version.minor, version.patch), + )) + } + } +} + +/// Non-Windows platforms don't route through WSL, so there's no helper to fetch. +#[cfg(not(target_os = "windows"))] +fn wsl_zed_release(_cx: &App) -> Option<(String, String)> { + None +} + async fn run_terminal_tool( project: Entity, environment: Rc, @@ -382,24 +416,32 @@ async fn run_terminal_tool( let selection = input.selection; let sandbox_input = input.sandbox.clone().unwrap_or_default(); - let (working_dir, authorize, sandboxing, is_local_project) = cx.update(|cx| { - let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; - let context = - crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); - let authorize = - event_stream.authorize(SharedString::new(input.command.clone()), context, cx); - let sandboxing = - input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); - let is_local_project = project.read(cx).is_local(); - Result::<_, String>::Ok((working_dir, authorize, sandboxing, is_local_project)) - })?; + let (working_dir, authorize, sandboxing, is_local_project, wsl_zed_release) = + cx.update(|cx| { + let working_dir = + working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; + let context = + crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); + let authorize = + event_stream.authorize(SharedString::new(input.command.clone()), context, cx); + let sandboxing = + input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); + let is_local_project = project.read(cx).is_local(); + let wsl_zed_release = wsl_zed_release(cx); + Result::<_, String>::Ok(( + working_dir, + authorize, + sandboxing, + is_local_project, + wsl_zed_release, + )) + })?; authorize.await.map_err(|e| e.to_string())?; let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true); let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true); let want_all_hosts = sandboxing && sandbox_input.allow_all_hosts == Some(true); - let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true); let persistent = cx.update(|cx| { agent_settings::AgentSettings::get_global(cx) @@ -455,8 +497,8 @@ async fn run_terminal_tool( { return Err( "Unrestricted filesystem writes are enabled for this thread, so every command \ - can already write anywhere; `fs_write_paths` cannot narrow that. Remove \ - `fs_write_paths`." + can already write anywhere except protected Git metadata; `fs_write_paths` \ + cannot narrow that. Remove `fs_write_paths`." .to_string(), ); } @@ -516,8 +558,10 @@ async fn run_terminal_tool( if !path.is_dir() { return Err(format!( "Cannot request sandbox write access to `{}`: on Linux, write access can only \ - be granted to directories that already exist. To create or modify files, \ - request write access to the existing directory that contains them, not the \ + be granted to directories that already exist. To create a new directory to write \ + into, use the `create_directory` tool (which creates it and grants write access to \ + exactly that directory) rather than requesting its parent. To modify existing \ + files, request write access to the existing directory that contains them, not the \ file path itself.", path.display() )); @@ -526,7 +570,6 @@ async fn run_terminal_tool( let request = crate::sandboxing::SandboxRequest { network, - allow_git_access: !want_unsandboxed && want_git_access, allow_fs_write_all: !want_unsandboxed && want_fs_write_all, unsandboxed: want_unsandboxed, write_paths, @@ -575,7 +618,7 @@ async fn run_terminal_tool( allow(unused_mut) )] let mut sandbox_not_applied: Option = None; - let mut git_access_downgrade_note = None; + let sandbox_wrap = if sandboxing && !want_unsandboxed { if unsandboxed_floor { // Every command in this thread runs unsandboxed because the user @@ -592,35 +635,20 @@ async fn run_terminal_tool( .to_string(), ); } - let (fs, sandbox_path_candidates) = cx.update(|cx| { + let (writable_paths, protected_paths) = cx.update(|cx| { ( - project.read(cx).fs().clone(), - SandboxGitPathCandidates::from_project(project.read(cx), cx), + sandbox_worktree_writable_paths(project.read(cx), cx), + sandbox_git_dirs(project.read(cx), cx), ) }); - let sandbox_paths = sandbox_git_paths( - sandbox_path_candidates, - fs.as_ref(), - effective.allow_git_access, - ) - .await; - if effective.allow_git_access && !sandbox_paths.allow_git_access { - log::warn!( - "Downgrading requested agent terminal Git metadata access because one or more external Git metadata paths could not be verified" - ); - git_access_downgrade_note = Some( - "Note: Git metadata access was requested or already allowed, but Zed could not verify one or more external Git metadata paths for this project. The command ran with Git metadata protected, so Git operations that read or write `.git` may fail with sandbox permission errors." - .to_string(), - ); - } let wrap = acp_thread::SandboxWrap { - writable_paths: sandbox_paths.writable_paths, + writable_paths, extra_write_paths: effective.write_paths, - git_dirs: sandbox_paths.git_dirs, - allow_git_access: sandbox_paths.allow_git_access, + protected_paths, network: network_request_to_sandbox_network_access(&effective.network), allow_fs_write: effective.allow_fs_write_all, is_local: is_local_project, + wsl_zed_release: wsl_zed_release.clone(), }; // The viability check runs a brief probe subprocess, so do it off @@ -637,10 +665,9 @@ async fn run_terminal_tool( let mut retries = 0usize; loop { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); let error = match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => break Some(wrap), @@ -658,6 +685,7 @@ async fn run_terminal_tool( event_stream.authorize_sandbox_fallback( Some(input.command.clone()), error.user_facing_message(), + Some(error.docs_section().to_string()), retries, cx, ) @@ -686,10 +714,9 @@ async fn run_terminal_tool( #[cfg(not(target_os = "linux"))] { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => Some(wrap), @@ -764,6 +791,7 @@ async fn run_terminal_tool( event_stream.authorize_sandbox_fallback( Some(input.command.clone()), sandbox_error.user_facing_message(), + Some(sandbox_error.docs_section().to_string()), retries, cx, ) @@ -902,13 +930,7 @@ async fn run_terminal_tool( let output = terminal.current_output(cx).map_err(|e| e.to_string())?; let result = process_content(output, &input.command, timed_out, user_stopped, selection); - let git_access_downgrade_note = (sandbox_wrap.is_some() && sandbox_not_applied.is_none()) - .then_some(git_access_downgrade_note) - .flatten(); - let notes = sandbox_note - .into_iter() - .chain(git_access_downgrade_note) - .collect::>(); + let notes = sandbox_note.into_iter().collect::>(); Ok(if notes.is_empty() { result } else { diff --git a/crates/agent/src/tools/terminal_tool/sandbox_git_paths.rs b/crates/agent/src/tools/terminal_tool/sandbox_git_paths.rs deleted file mode 100644 index 4133d5d15cefc5..00000000000000 --- a/crates/agent/src/tools/terminal_tool/sandbox_git_paths.rs +++ /dev/null @@ -1,1416 +0,0 @@ -use fs::Fs; -use gpui::App; -use project::Project; -use std::path::{Path, PathBuf}; - -#[derive(Default)] -pub(crate) struct SandboxGitPathCandidates { - pub(crate) writable_paths: Vec, - pub(crate) git_paths: Vec, - repositories: Vec, -} - -struct SandboxGitRepositoryPaths { - work_directory_abs_path: PathBuf, - dot_git_abs_path: PathBuf, - repository_dir_abs_path: PathBuf, - common_dir_abs_path: PathBuf, -} - -pub(crate) struct SandboxGitPaths { - pub(crate) writable_paths: Vec, - pub(crate) git_dirs: Vec, - pub(crate) allow_git_access: bool, -} - -impl SandboxGitPathCandidates { - pub(crate) fn cache_key_repositories(&self) -> Vec<(PathBuf, PathBuf, PathBuf, PathBuf)> { - let mut repositories = self - .repositories - .iter() - .map(|repository| { - ( - repository.work_directory_abs_path.clone(), - repository.dot_git_abs_path.clone(), - repository.repository_dir_abs_path.clone(), - repository.common_dir_abs_path.clone(), - ) - }) - .collect::>(); - repositories.sort(); - repositories - } - - pub(crate) fn from_project(project: &Project, cx: &App) -> Self { - let mut candidates = Self::default(); - - for worktree in project.worktrees(cx) { - let worktree = worktree.read(cx); - let worktree_abs_path = worktree.abs_path(); - candidates - .writable_paths - .push(worktree_abs_path.to_path_buf()); - // Protect `/.git` even when it doesn't exist yet, so a command - // can't `git init` and then write to the freshly created metadata. - candidates.git_paths.push(worktree_abs_path.join(".git")); - - // `Worktree` derefs to `Snapshot`; read the field directly instead of - // cloning the whole snapshot just for this path. - if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() { - candidates - .git_paths - .push(root_repo_common_dir.to_path_buf()); - } - } - - // `Repository` derefs to `RepositorySnapshot`, so read the few path fields - // directly rather than cloning the entire snapshot (which carries the - // per-path status tree) for each repository. - for repository in project.git_store().read(cx).repositories().values() { - let repository = repository.read(cx); - let repository_paths = SandboxGitRepositoryPaths { - work_directory_abs_path: repository.work_directory_abs_path.to_path_buf(), - dot_git_abs_path: repository.dot_git_abs_path.to_path_buf(), - repository_dir_abs_path: repository.repository_dir_abs_path.to_path_buf(), - common_dir_abs_path: repository.common_dir_abs_path.to_path_buf(), - }; - candidates - .git_paths - .push(repository_paths.dot_git_abs_path.clone()); - candidates - .git_paths - .push(repository_paths.repository_dir_abs_path.clone()); - candidates - .git_paths - .push(repository_paths.common_dir_abs_path.clone()); - candidates.repositories.push(repository_paths); - } - - candidates.git_paths.sort(); - candidates.git_paths.dedup(); - candidates.writable_paths.sort(); - candidates.writable_paths.dedup(); - - candidates - } -} - -pub(crate) async fn sandbox_git_paths( - candidates: SandboxGitPathCandidates, - fs: &dyn Fs, - allow_git_access: bool, -) -> SandboxGitPaths { - let mut writable_paths = candidates.writable_paths; - let mut git_dirs = candidates.git_paths; - - let mut allow_verified_git_access = false; - if allow_git_access { - let mut verified_git_paths = Vec::new(); - for repository in candidates.repositories { - verified_git_paths.extend(verified_sandbox_git_paths(repository, fs).await); - } - verified_git_paths.sort(); - verified_git_paths.dedup(); - - // A Git path inside a writable worktree root is already writable, so - // granting it can never escalate access beyond the project. A non-repo - // `.git` placeholder there (a plain folder opened alongside a repo, or a - // not-yet-initialized repo) would never appear in `verified_git_paths`, - // so requiring it to verify would wrongly deny the whole grant. Only - // paths that fall *outside* every writable root can leak access to - // unrelated metadata, so those are the only ones that must verify. - let mut all_external_git_paths_verified = true; - for path in &git_dirs { - if path_is_within_any(path, &writable_paths) { - continue; - } - let Some(normalized_path) = normalize_sandbox_git_path(path, fs).await else { - log::warn!( - "Denying requested agent terminal Git metadata access because external Git metadata path `{}` could not be normalized", - path.display() - ); - all_external_git_paths_verified = false; - break; - }; - if verified_git_paths.binary_search(&normalized_path).is_err() { - log::warn!( - "Denying requested agent terminal Git metadata access because external Git metadata path `{}` (normalized to `{}`) was not verified from project repository metadata", - path.display(), - normalized_path.display() - ); - all_external_git_paths_verified = false; - break; - } - } - - // The current sandbox policy can make one Git directory set either all - // writable or all protected. Only grant Git access when every external - // candidate still verifies; otherwise keep protecting the original - // candidate set. The granted set is the verified paths only, so even - // when the grant proceeds, unverified `.git` metadata never becomes - // writable. - if all_external_git_paths_verified { - git_dirs = verified_git_paths; - allow_verified_git_access = true; - } - } - - git_dirs.sort(); - git_dirs.dedup(); - writable_paths.sort(); - writable_paths.dedup(); - - SandboxGitPaths { - writable_paths, - git_dirs, - allow_git_access: allow_verified_git_access, - } -} - -async fn verified_sandbox_git_paths( - repository: SandboxGitRepositoryPaths, - fs: &dyn Fs, -) -> Vec { - macro_rules! deny { - ($($arg:tt)*) => {{ - log::debug!( - "Denying agent terminal Git metadata access for repository `{}` (dot_git: `{}`, repository_dir: `{}`, common_dir: `{}`): {}", - repository.work_directory_abs_path.display(), - repository.dot_git_abs_path.display(), - repository.repository_dir_abs_path.display(), - repository.common_dir_abs_path.display(), - format_args!($($arg)*) - ); - return Vec::new(); - }}; - } - - let Some(dot_git_abs_path) = normalize_sandbox_git_path(&repository.dot_git_abs_path, fs).await - else { - deny!( - "could not normalize .git path `{}`", - repository.dot_git_abs_path.display() - ); - }; - let Some(repository_dir_abs_path) = - normalize_sandbox_git_path(&repository.repository_dir_abs_path, fs).await - else { - deny!( - "could not normalize repository dir `{}`", - repository.repository_dir_abs_path.display() - ); - }; - let Some(common_dir_abs_path) = - normalize_sandbox_git_path(&repository.common_dir_abs_path, fs).await - else { - deny!( - "could not normalize common dir `{}`", - repository.common_dir_abs_path.display() - ); - }; - - let dot_git_metadata = match fs.metadata(&repository.dot_git_abs_path).await { - Ok(Some(metadata)) => metadata, - Ok(None) => deny!( - ".git path `{}` does not exist", - repository.dot_git_abs_path.display() - ), - Err(error) => deny!( - "failed to read metadata for .git path `{}`: {error}", - repository.dot_git_abs_path.display() - ), - }; - if dot_git_metadata.is_symlink { - deny!( - ".git path `{}` is a symlink", - repository.dot_git_abs_path.display() - ); - } - - if dot_git_metadata.is_dir { - if dot_git_abs_path != repository_dir_abs_path { - deny!( - "directory .git path `{}` normalized to `{}`, which does not match repository dir `{}` normalized to `{}`", - repository.dot_git_abs_path.display(), - dot_git_abs_path.display(), - repository.repository_dir_abs_path.display(), - repository_dir_abs_path.display() - ); - } - - if repository_dir_abs_path == common_dir_abs_path { - return vec![ - dot_git_abs_path, - repository_dir_abs_path, - common_dir_abs_path, - ]; - } - - let Some(common_dir) = read_commondir_path(&repository_dir_abs_path, fs).await else { - deny!( - "repository dir `{}` did not contain a readable commondir pointing at expected common dir `{}`", - repository_dir_abs_path.display(), - common_dir_abs_path.display() - ); - }; - if common_dir == common_dir_abs_path { - return vec![ - dot_git_abs_path, - repository_dir_abs_path, - common_dir_abs_path, - ]; - } - deny!( - "repository dir `{}` commondir resolved to `{}`, expected `{}`", - repository_dir_abs_path.display(), - common_dir.display(), - common_dir_abs_path.display() - ); - } - - let Some(expected_dot_git_abs_path) = - normalize_sandbox_git_path(repository.work_directory_abs_path.join(".git"), fs).await - else { - deny!( - "could not normalize expected worktree .git path `{}`", - repository.work_directory_abs_path.join(".git").display() - ); - }; - if dot_git_abs_path != expected_dot_git_abs_path { - deny!( - ".git path `{}` normalized to `{}`, expected worktree .git path `{}`", - repository.dot_git_abs_path.display(), - dot_git_abs_path.display(), - expected_dot_git_abs_path.display() - ); - } - - let Some(stated_repository_dir) = read_gitfile_path(&repository.dot_git_abs_path, fs).await - else { - deny!( - "gitfile `{}` did not resolve to a readable, non-symlink repository dir", - repository.dot_git_abs_path.display() - ); - }; - - if stated_repository_dir != repository_dir_abs_path { - deny!( - "gitfile `{}` resolved to repository dir `{}`, expected `{}`", - repository.dot_git_abs_path.display(), - stated_repository_dir.display(), - repository_dir_abs_path.display() - ); - } - - let Some(common_dir) = read_commondir_path(&stated_repository_dir, fs).await else { - if repository_dir_abs_path == common_dir_abs_path - && gitdir_belongs_to_submodule_worktree( - &repository_dir_abs_path, - &repository.work_directory_abs_path, - fs, - ) - .await - { - return vec![dot_git_abs_path, repository_dir_abs_path]; - } - deny!( - "repository dir `{}` has no verified commondir and did not verify as a submodule gitdir for worktree `{}`", - repository_dir_abs_path.display(), - repository.work_directory_abs_path.display() - ); - }; - - if common_dir != common_dir_abs_path { - deny!( - "repository dir `{}` commondir resolved to `{}`, expected `{}`", - stated_repository_dir.display(), - common_dir.display(), - common_dir_abs_path.display() - ); - } - - if repository_dir_abs_path != common_dir_abs_path - && !linked_worktree_points_back( - &common_dir_abs_path, - &repository_dir_abs_path, - &dot_git_abs_path, - &repository.work_directory_abs_path, - fs, - ) - .await - { - deny!( - "linked worktree repository dir `{}` did not point back to .git path `{}` and worktree `{}` under common dir `{}`", - repository_dir_abs_path.display(), - dot_git_abs_path.display(), - repository.work_directory_abs_path.display(), - common_dir_abs_path.display() - ); - } - - vec![ - dot_git_abs_path, - repository_dir_abs_path, - common_dir_abs_path, - ] -} - -async fn read_gitfile_path(dot_git_abs_path: &Path, fs: &dyn Fs) -> Option { - let contents = match fs.load(dot_git_abs_path).await { - Ok(contents) => contents, - Err(error) => { - log::debug!( - "Could not verify Git metadata path: failed to read gitfile `{}`: {error}", - dot_git_abs_path.display() - ); - return None; - } - }; - let Some(gitdir) = contents.strip_prefix("gitdir:") else { - log::debug!( - "Could not verify Git metadata path: gitfile `{}` does not start with `gitdir:`", - dot_git_abs_path.display() - ); - return None; - }; - let gitdir = Path::new(gitdir.trim()); - let Some(dot_git_parent) = dot_git_abs_path.parent() else { - log::debug!( - "Could not verify Git metadata path: gitfile `{}` has no parent directory", - dot_git_abs_path.display() - ); - return None; - }; - let path = if gitdir.is_absolute() { - gitdir.to_path_buf() - } else { - dot_git_parent.join(gitdir) - }; - match fs.metadata(&path).await { - Ok(Some(metadata)) if metadata.is_symlink => { - log::debug!( - "Could not verify Git metadata path: gitfile `{}` points to symlinked gitdir `{}`", - dot_git_abs_path.display(), - path.display() - ); - return None; - } - Ok(_) => {} - Err(error) => { - log::debug!( - "Could not check whether gitfile `{}` points to a symlink at `{}`: {error}", - dot_git_abs_path.display(), - path.display() - ); - } - } - let normalized_path = normalize_sandbox_git_path(&path, fs).await; - if normalized_path.is_none() { - log::debug!( - "Could not verify Git metadata path: gitfile `{}` points to gitdir `{}` that could not be normalized", - dot_git_abs_path.display(), - path.display() - ); - } - normalized_path -} - -async fn read_commondir_path(repository_dir_abs_path: &Path, fs: &dyn Fs) -> Option { - let commondir_abs_path = repository_dir_abs_path.join("commondir"); - let commondir_contents = match fs.load(&commondir_abs_path).await { - Ok(contents) => contents, - Err(error) => { - log::debug!( - "Could not verify Git metadata path: failed to read commondir file `{}`: {error}", - commondir_abs_path.display() - ); - return None; - } - }; - let commondir_path = Path::new(commondir_contents.trim()); - let path = if commondir_path.is_absolute() { - commondir_path.to_path_buf() - } else { - repository_dir_abs_path.join(commondir_path) - }; - let normalized_path = normalize_sandbox_git_path(&path, fs).await; - if normalized_path.is_none() { - log::debug!( - "Could not verify Git metadata path: commondir file `{}` points to `{}` which could not be normalized", - commondir_abs_path.display(), - path.display() - ); - } - normalized_path -} - -async fn linked_worktree_points_back( - common_dir_abs_path: &Path, - repository_dir_abs_path: &Path, - dot_git_abs_path: &Path, - work_directory_abs_path: &Path, - fs: &dyn Fs, -) -> bool { - let expected_repository_parent = common_dir_abs_path.join("worktrees"); - if repository_dir_abs_path.parent() != Some(expected_repository_parent.as_path()) { - log::debug!( - "Could not verify linked worktree Git metadata: repository dir `{}` is not under expected worktrees dir `{}`", - repository_dir_abs_path.display(), - expected_repository_parent.display() - ); - return false; - } - - match fs.metadata(repository_dir_abs_path).await { - Ok(Some(metadata)) if metadata.is_dir && !metadata.is_symlink => {} - Ok(Some(metadata)) => { - log::debug!( - "Could not verify linked worktree Git metadata: repository dir `{}` has invalid metadata (is_dir: {}, is_symlink: {})", - repository_dir_abs_path.display(), - metadata.is_dir, - metadata.is_symlink - ); - return false; - } - Ok(None) => { - log::debug!( - "Could not verify linked worktree Git metadata: repository dir `{}` does not exist", - repository_dir_abs_path.display() - ); - return false; - } - Err(error) => { - log::debug!( - "Could not verify linked worktree Git metadata: failed to read metadata for repository dir `{}`: {error}", - repository_dir_abs_path.display() - ); - return false; - } - } - - let expected_dot_git_abs_path = work_directory_abs_path.join(".git"); - let Some(expected_dot_git_abs_path) = - normalize_sandbox_git_path(&expected_dot_git_abs_path, fs).await - else { - log::debug!( - "Could not verify linked worktree Git metadata: expected .git path `{}` could not be normalized", - expected_dot_git_abs_path.display() - ); - return false; - }; - if dot_git_abs_path != expected_dot_git_abs_path { - log::debug!( - "Could not verify linked worktree Git metadata: .git path `{}` does not match expected worktree .git path `{}`", - dot_git_abs_path.display(), - expected_dot_git_abs_path.display() - ); - return false; - } - - let Some(listed_dot_git_path) = read_listed_worktree_gitdir(repository_dir_abs_path, fs).await - else { - return false; - }; - if listed_dot_git_path != dot_git_abs_path { - log::debug!( - "Could not verify linked worktree Git metadata: repository dir `{}` lists .git path `{}`, expected `{}`", - repository_dir_abs_path.display(), - listed_dot_git_path.display(), - dot_git_abs_path.display() - ); - return false; - } - - true -} - -async fn read_listed_worktree_gitdir(worktree_entry_dir: &Path, fs: &dyn Fs) -> Option { - let gitdir_abs_path = worktree_entry_dir.join("gitdir"); - let gitdir_contents = match fs.load(&gitdir_abs_path).await { - Ok(contents) => contents, - Err(error) => { - log::debug!( - "Could not verify linked worktree Git metadata: failed to read worktree gitdir file `{}`: {error}", - gitdir_abs_path.display() - ); - return None; - } - }; - let gitdir_path = Path::new(gitdir_contents.trim()); - let path = if gitdir_path.is_absolute() { - gitdir_path.to_path_buf() - } else { - worktree_entry_dir.join(gitdir_path) - }; - let normalized_path = normalize_sandbox_git_path(&path, fs).await; - if normalized_path.is_none() { - log::debug!( - "Could not verify linked worktree Git metadata: worktree gitdir file `{}` points to `{}` which could not be normalized", - gitdir_abs_path.display(), - path.display() - ); - } - normalized_path -} - -async fn gitdir_belongs_to_submodule_worktree( - repository_dir_abs_path: &Path, - work_directory_abs_path: &Path, - fs: &dyn Fs, -) -> bool { - let Some(work_directory_abs_path) = - normalize_sandbox_git_path(work_directory_abs_path, fs).await - else { - log::debug!( - "Could not verify submodule Git metadata: worktree path `{}` could not be normalized", - work_directory_abs_path.display() - ); - return false; - }; - - let Some(core_worktree) = read_core_worktree(repository_dir_abs_path, fs).await else { - return false; - }; - if core_worktree != work_directory_abs_path { - log::debug!( - "Could not verify submodule Git metadata: repository dir `{}` has core.worktree `{}`, expected `{}`", - repository_dir_abs_path.display(), - core_worktree.display(), - work_directory_abs_path.display() - ); - return false; - } - - true -} - -async fn read_core_worktree(repository_dir_abs_path: &Path, fs: &dyn Fs) -> Option { - let config_abs_path = repository_dir_abs_path.join("config"); - let config = match fs.load(&config_abs_path).await { - Ok(config) => config, - Err(error) => { - log::debug!( - "Could not verify submodule Git metadata: failed to read config `{}`: {error}", - config_abs_path.display() - ); - return None; - } - }; - let Some(core_worktree) = parse_core_worktree(&config) else { - log::debug!( - "Could not verify submodule Git metadata: config `{}` did not contain exactly one supported core.worktree value", - config_abs_path.display() - ); - return None; - }; - let path = Path::new(&core_worktree); - let path = if path.is_absolute() { - path.to_path_buf() - } else { - repository_dir_abs_path.join(path) - }; - let normalized_path = normalize_sandbox_git_path(&path, fs).await; - if normalized_path.is_none() { - log::debug!( - "Could not verify submodule Git metadata: core.worktree value `{}` from config `{}` resolved to `{}` which could not be normalized", - core_worktree, - config_abs_path.display(), - path.display() - ); - } - normalized_path -} - -fn parse_core_worktree(config: &str) -> Option { - let mut in_core_section = false; - let mut core_worktree = None; - - for raw_line in config.lines() { - let line = raw_line.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with(';') { - continue; - } - if line.ends_with('\\') { - return None; - } - - if line.starts_with('[') { - if !line.ends_with(']') { - return None; - } - let section = line[1..line.len() - 1].trim(); - if section.to_lowercase().starts_with("include") { - return None; - } - in_core_section = section.eq_ignore_ascii_case("core"); - continue; - } - - if !in_core_section { - continue; - } - - let Some((key, value)) = line.split_once('=') else { - continue; - }; - if !key.trim().eq_ignore_ascii_case("worktree") { - continue; - } - if core_worktree.is_some() { - return None; - } - core_worktree = Some(parse_git_config_path_value(value.trim())?); - } - - core_worktree -} - -fn parse_git_config_path_value(value: &str) -> Option { - if value.is_empty() { - return None; - } - - if !value.starts_with('"') { - if value.contains('"') || value.starts_with('~') { - return None; - } - return Some(value.to_string()); - } - - let mut chars = value.chars(); - chars.next()?; - let mut parsed = String::new(); - let mut escaped = false; - let mut closed = false; - while let Some(character) = chars.next() { - if escaped { - match character { - '"' | '\\' => parsed.push(character), - _ => return None, - } - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == '"' { - closed = true; - break; - } else { - parsed.push(character); - } - } - - if escaped || !closed { - return None; - } - - let remaining = &value[value.len() - chars.as_str().len()..]; - if !remaining.trim().is_empty() { - return None; - } - - if parsed.is_empty() || parsed.starts_with('~') { - return None; - } - - Some(parsed) -} - -fn path_is_within_any(path: &Path, roots: &[PathBuf]) -> bool { - // `Path::starts_with` matches whole components, so `/projectX` is not - // treated as being within `/project`. - roots.iter().any(|root| path.starts_with(root)) -} - -async fn normalize_sandbox_git_path(path: impl AsRef, fs: &dyn Fs) -> Option { - if let Ok(path) = fs.canonicalize(path.as_ref()).await { - Some(path) - } else { - util::paths::normalize_lexically(path.as_ref()).ok() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::Fs; - - #[gpui::test] - async fn test_sandbox_paths_protect_git_paths_until_git_access_is_allowed( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/main_repo", - serde_json::json!({ - ".git": {}, - "file.txt": "content", - }), - ) - .await; - fs.add_linked_worktree_for_repo( - Path::new("/main_repo/.git"), - false, - git::repository::Worktree { - path: PathBuf::from("/linked_worktree"), - ref_name: Some("refs/heads/feature".into()), - sha: "abc123".into(), - is_main: false, - is_bare: false, - }, - ) - .await; - fs.write(Path::new("/linked_worktree/file.txt"), b"content") - .await - .expect("linked worktree file should be written"); - - let project = project::Project::test(fs.clone(), [Path::new("/linked_worktree")], cx).await; - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_without_git_access = sandbox_git_paths(candidates, fs.as_ref(), false).await; - - assert!( - paths_without_git_access - .writable_paths - .contains(&PathBuf::from("/linked_worktree")) - ); - assert!( - paths_without_git_access - .git_dirs - .contains(&PathBuf::from("/linked_worktree/.git")) - ); - assert!( - !paths_without_git_access - .git_dirs - .contains(&PathBuf::from("/linked_worktree/.gitignore")) - ); - assert!( - paths_without_git_access - .git_dirs - .contains(&PathBuf::from("/main_repo/.git")) - ); - assert!( - paths_without_git_access - .git_dirs - .contains(&PathBuf::from("/main_repo/.git/worktrees/feature")) - ); - - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/linked_worktree")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/linked_worktree/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/main_repo/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/main_repo/.git/worktrees/feature")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_grant_git_access_when_non_git_folder_is_present( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/repo", - serde_json::json!({ - ".git": {}, - "file.txt": "content", - }), - ) - .await; - // A plain folder opened alongside the repo. Its `/.git` placeholder - // never corresponds to a repository, so it must not block the grant for - // the real repo. - fs.insert_tree("/notes", serde_json::json!({ "todo.txt": "hi" })) - .await; - - let project = - project::Project::test(fs.clone(), [Path::new("/repo"), Path::new("/notes")], cx).await; - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/repo/.git")) - ); - assert!( - paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/notes")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_allow_submodule_gitdir_without_commondir( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/super", - serde_json::json!({ - ".git": { - "modules": { - "sub": { - "HEAD": "ref: refs/heads/main", - "config": "[core]\n\trepositoryformatversion = 0\n\tworktree = ../../../sub\n" - } - } - }, - "sub": { - ".git": "gitdir: ../.git/modules/sub", - "file.txt": "content" - } - }), - ) - .await; - - let project = project::Project::test(fs.clone(), [Path::new("/super/sub")], cx).await; - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/super/sub")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/super/sub/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/super/.git/modules/sub")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_do_not_grant_submodule_gitdir_without_back_reference( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/super", - serde_json::json!({ - ".git": { - "modules": { - "sub": { - "HEAD": "ref: refs/heads/main", - "config": "[core]\n\trepositoryformatversion = 0\n" - } - } - }, - "sub": { - ".git": "gitdir: ../.git/modules/sub", - "file.txt": "content" - } - }), - ) - .await; - - let project = project::Project::test(fs.clone(), [Path::new("/super/sub")], cx).await; - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(!paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/super/sub/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/super/.git/modules/sub")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/super/.git/modules/sub")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_do_not_grant_submodule_gitfile_to_unrelated_gitdir( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - serde_json::json!({ - "sub": { - ".git": "gitdir: /other_repo/.git", - "file.txt": "content" - } - }), - ) - .await; - fs.insert_tree( - "/other_repo", - serde_json::json!({ - ".git": { - "HEAD": "ref: refs/heads/main", - "config": "[core]\n\trepositoryformatversion = 0\n" - } - }), - ) - .await; - - let project = project::Project::test(fs.clone(), [Path::new("/project/sub")], cx).await; - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(!paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/project/sub/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/other_repo/.git")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_do_not_follow_gitfile_changed_after_scan( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/main_repo", - serde_json::json!({ - ".git": {}, - "file.txt": "content", - }), - ) - .await; - fs.add_linked_worktree_for_repo( - Path::new("/main_repo/.git"), - false, - git::repository::Worktree { - path: PathBuf::from("/linked_worktree"), - ref_name: Some("refs/heads/feature".into()), - sha: "abc123".into(), - is_main: false, - is_bare: false, - }, - ) - .await; - fs.write(Path::new("/linked_worktree/file.txt"), b"content") - .await - .expect("linked worktree file should be written"); - fs.insert_tree( - "/other_repo", - serde_json::json!({ - ".git": { - "worktrees": { - "other": { - "HEAD": "ref: refs/heads/other", - "commondir": "/other_repo/.git", - "gitdir": "/other_worktree/.git" - } - } - } - }), - ) - .await; - - let project = project::Project::test(fs.clone(), [Path::new("/linked_worktree")], cx).await; - fs.write( - Path::new("/linked_worktree/.git"), - b"gitdir: /other_repo/.git/worktrees/other", - ) - .await - .expect("mutated gitfile should be written"); - - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(!paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/linked_worktree/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/main_repo/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/main_repo/.git/worktrees/feature")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git/worktrees/other")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_do_not_grant_unverified_worktree_gitdir( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/main_repo", - serde_json::json!({ - ".git": {}, - "file.txt": "content", - }), - ) - .await; - fs.add_linked_worktree_for_repo( - Path::new("/main_repo/.git"), - false, - git::repository::Worktree { - path: PathBuf::from("/linked_worktree"), - ref_name: Some("refs/heads/feature".into()), - sha: "abc123".into(), - is_main: false, - is_bare: false, - }, - ) - .await; - fs.insert_tree( - "/other_repo", - serde_json::json!({ - ".git": { - "worktrees": { - "other": { - "HEAD": "ref: refs/heads/other", - "commondir": "/other_repo/.git", - "gitdir": "/other_worktree/.git" - } - } - } - }), - ) - .await; - fs.write( - Path::new("/linked_worktree/.git"), - b"gitdir: /other_repo/.git/worktrees/other", - ) - .await - .expect("malicious gitfile should be written"); - - let project = project::Project::test(fs.clone(), [Path::new("/linked_worktree")], cx).await; - let candidates = - cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx)); - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(!paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/linked_worktree")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git/worktrees/other")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/linked_worktree/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/other_repo/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/other_repo/.git/worktrees/other")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_do_not_grant_symlinked_dot_git(cx: &mut gpui::TestAppContext) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - serde_json::json!({ - "file.txt": "content", - }), - ) - .await; - fs.insert_tree( - "/other_repo", - serde_json::json!({ - ".git": {} - }), - ) - .await; - fs.insert_symlink( - Path::new("/project/.git"), - PathBuf::from("/other_repo/.git"), - ) - .await; - - let candidates = SandboxGitPathCandidates { - writable_paths: vec![PathBuf::from("/project")], - git_paths: vec![ - PathBuf::from("/project/.git"), - PathBuf::from("/other_repo/.git"), - ], - repositories: vec![SandboxGitRepositoryPaths { - work_directory_abs_path: PathBuf::from("/project"), - dot_git_abs_path: PathBuf::from("/project/.git"), - repository_dir_abs_path: PathBuf::from("/other_repo/.git"), - common_dir_abs_path: PathBuf::from("/other_repo/.git"), - }], - }; - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(!paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/project/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/other_repo/.git")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_do_not_grant_symlinked_dot_git_file(cx: &mut gpui::TestAppContext) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - serde_json::json!({ - "file.txt": "content", - }), - ) - .await; - fs.insert_tree( - "/other_repo", - serde_json::json!({ - "gitfile": "gitdir: /other_repo/.git", - ".git": { - "HEAD": "ref: refs/heads/main", - "config": "[core]\n\trepositoryformatversion = 0\n\tworktree = /project\n" - } - }), - ) - .await; - fs.insert_symlink( - Path::new("/project/.git"), - PathBuf::from("/other_repo/gitfile"), - ) - .await; - - let candidates = SandboxGitPathCandidates { - writable_paths: vec![PathBuf::from("/project")], - git_paths: vec![ - PathBuf::from("/project/.git"), - PathBuf::from("/other_repo/.git"), - ], - repositories: vec![SandboxGitRepositoryPaths { - work_directory_abs_path: PathBuf::from("/project"), - dot_git_abs_path: PathBuf::from("/project/.git"), - repository_dir_abs_path: PathBuf::from("/other_repo/.git"), - common_dir_abs_path: PathBuf::from("/other_repo/.git"), - }], - }; - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(!paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/project/.git")) - ); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/other_repo/.git")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git")) - ); - } - - #[gpui::test] - async fn test_sandbox_paths_do_not_grant_gitfile_to_symlinked_gitdir( - cx: &mut gpui::TestAppContext, - ) { - crate::tests::init_test(cx); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": "gitdir: /other_repo/gitdir_link", - "file.txt": "content", - }), - ) - .await; - fs.insert_tree( - "/other_repo", - serde_json::json!({ - ".git": { - "HEAD": "ref: refs/heads/main", - "config": "[core]\n\trepositoryformatversion = 0\n\tworktree = /project\n" - } - }), - ) - .await; - fs.insert_symlink( - Path::new("/other_repo/gitdir_link"), - PathBuf::from("/other_repo/.git"), - ) - .await; - - let candidates = SandboxGitPathCandidates { - writable_paths: vec![PathBuf::from("/project")], - git_paths: vec![ - PathBuf::from("/project/.git"), - PathBuf::from("/other_repo/gitdir_link"), - PathBuf::from("/other_repo/.git"), - ], - repositories: vec![SandboxGitRepositoryPaths { - work_directory_abs_path: PathBuf::from("/project"), - dot_git_abs_path: PathBuf::from("/project/.git"), - repository_dir_abs_path: PathBuf::from("/other_repo/gitdir_link"), - common_dir_abs_path: PathBuf::from("/other_repo/gitdir_link"), - }], - }; - let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await; - - assert!(!paths_with_git_access.allow_git_access); - assert!( - paths_with_git_access - .git_dirs - .contains(&PathBuf::from("/other_repo/gitdir_link")) - ); - assert!( - !paths_with_git_access - .writable_paths - .contains(&PathBuf::from("/other_repo/.git")) - ); - } - - #[test] - fn test_parse_core_worktree_accepts_simple_and_quoted_values() { - assert_eq!( - parse_core_worktree("[core]\n\tworktree = ../../../sub\n"), - Some("../../../sub".to_string()) - ); - assert_eq!( - parse_core_worktree("[core]\n\tworktree = \"../../../sub with spaces\"\n"), - Some("../../../sub with spaces".to_string()) - ); - assert_eq!( - parse_core_worktree("[core]\n\tworktree = \"C:/Users/Test/project/sub\"\n"), - Some("C:/Users/Test/project/sub".to_string()) - ); - assert_eq!( - parse_core_worktree("[core]\n\tworktree = \"C:\\\\Users\\\\Test\\\\project\\\\sub\"\n"), - Some("C:\\Users\\Test\\project\\sub".to_string()) - ); - } - - #[test] - fn test_parse_core_worktree_rejects_ambiguous_or_unsupported_config() { - assert_eq!(parse_core_worktree("[core]\n\tworktree =\n"), None); - assert_eq!( - parse_core_worktree("[core]\n\tworktree = ../../../sub\n\tworktree = ../../../other\n"), - None - ); - assert_eq!(parse_core_worktree("worktree = ../../../sub\n"), None); - assert_eq!( - parse_core_worktree( - "[include]\n\tpath = ../config\n[core]\n\tworktree = ../../../sub\n" - ), - None - ); - assert_eq!( - parse_core_worktree("[core]\n\tworktree = \"../../../sub\" trailing\n"), - None - ); - assert_eq!( - parse_core_worktree("[core]\n\tworktree = \"../../../sub\n"), - None - ); - assert_eq!( - parse_core_worktree("[core]\n\tworktree = ../../../sub\\\n"), - None - ); - assert_eq!(parse_core_worktree("[core]\n\tworktree = ~/sub\n"), None); - } -} diff --git a/crates/agent_detect/Cargo.toml b/crates/agent_detect/Cargo.toml new file mode 100644 index 00000000000000..8b79b0bedffb92 --- /dev/null +++ b/crates/agent_detect/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agent_detect" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_detect.rs" + +[dependencies] +regex.workspace = true diff --git a/crates/agent_detect/src/agent_detect.rs b/crates/agent_detect/src/agent_detect.rs new file mode 100644 index 00000000000000..190c26e9943b0c --- /dev/null +++ b/crates/agent_detect/src/agent_detect.rs @@ -0,0 +1,1370 @@ +//! Screen-scraping status detection for agent CLIs running inside terminals. +//! +//! Given the tail of a terminal's screen content plus its OSC title, classifies +//! what the agent process is doing: working, blocked on the user, or idle. +//! Rules match against narrow regions of the screen (the live prompt box, the +//! text after the last horizontal rule, ...) rather than the whole buffer so +//! that stale prompts in scrollback and spinner frames don't cause false +//! positives. + +use std::sync::LazyLock; +use std::time::{Duration, Instant}; + +use regex::Regex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AgentKind { + ClaudeCode, + Codex, +} + +impl AgentKind { + /// Identify an agent from a foreground process command name (e.g. the + /// value of `Terminal::foreground_process_command_name`). + pub fn from_command_name(command: &str) -> Option { + let name = command + .rsplit(['/', '\\']) + .next() + .unwrap_or(command) + .trim() + .to_ascii_lowercase(); + let name = name.strip_suffix(".exe").unwrap_or(&name); + match name { + "claude" | "claude-code" => Some(Self::ClaudeCode), + "codex" | "codex-cli" => Some(Self::Codex), + _ => None, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::ClaudeCode => "claude", + Self::Codex => "codex", + } + } + + /// The executable used to launch this agent. + pub fn executable(&self) -> &'static str { + match self { + Self::ClaudeCode => "claude", + Self::Codex => "codex", + } + } + + /// Command-line for resuming a previously captured session. The session + /// reference is passed as argv data and must never be shell-interpolated. + pub fn resume_argv(&self, session_ref: &str) -> Vec { + match self { + Self::ClaudeCode => vec![ + "claude".into(), + "--resume".into(), + session_ref.into(), + ], + Self::Codex => vec!["codex".into(), "resume".into(), session_ref.into()], + } + } + + /// Command-line to use when the agent should be relaunched but no session + /// was captured. Claude Code can resume the most recent conversation for + /// the working directory; codex has no per-directory equivalent, so it + /// starts fresh. + pub fn relaunch_argv(&self) -> Vec { + match self { + Self::ClaudeCode => vec!["claude".into(), "--continue".into()], + Self::Codex => vec!["codex".into()], + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentState { + Idle, + Working, + Blocked, + #[default] + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Detection { + pub state: AgentState, + /// The live screen chrome itself shows this state (e.g. an empty prompt + /// box for idle, a permission form for blocked) — stronger evidence than + /// text found in scrollback. + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, + /// The screen is showing a transcript viewer or similar overlay; its text + /// must not be used to update state. + pub skip_state_update: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct DetectionInput<'a> { + /// Tail of the terminal screen (the last ~40+ lines, newline-joined). + pub screen: &'a str, + /// The terminal's OSC 0/2 title as most recently set by the process. + pub osc_title: &'a str, +} + +/// Classify the agent's current state from the screen tail and OSC title. +/// Returns an idle detection when no rule matches: a known agent showing +/// nothing recognizable is assumed to be sitting at rest. +pub fn detect(agent: AgentKind, input: DetectionInput<'_>) -> Detection { + let rules = rules_for(agent); + let mut best: Option<&Rule> = None; + for rule in rules { + let region_text = slice_region(input, rule.region); + let lower = region_text.to_lowercase(); + if !gate_matches(&rule.gate, region_text, &lower) { + continue; + } + match best { + Some(previous) if previous.priority >= rule.priority => {} + _ => best = Some(rule), + } + } + + match best { + Some(rule) => Detection { + state: rule.state, + visible_idle: rule.visible_idle && rule.state == AgentState::Idle, + visible_blocker: rule.visible_blocker && rule.state == AgentState::Blocked, + visible_working: rule.visible_working && rule.state == AgentState::Working, + skip_state_update: rule.skip_state_update, + }, + None => Detection { + state: AgentState::Idle, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + }, + } +} + +/// Debounces Working -> Idle flapping: a momentary gap between spinner frames +/// must not flash the status to idle. The transition is held until it has been +/// re-observed [`Self::CONFIRMATIONS`] times or [`Self::HOLD_CAP`] has passed. +/// Blocked transitions and visibly-idle screens publish immediately. +#[derive(Debug, Default)] +pub struct StatusTracker { + published: Option, + pending_idle_started_at: Option, + pending_idle_confirmations: u8, +} + +impl StatusTracker { + const CONFIRMATIONS: u8 = 3; + const HOLD_CAP: Duration = Duration::from_millis(700); + + pub fn published_state(&self) -> Option { + self.published + } + + /// A Working -> Idle transition is currently being held for confirmation. + /// Callers should re-run detection shortly even if the terminal produces + /// no further output, since a finished agent goes quiet. + pub fn is_holding_idle(&self) -> bool { + self.pending_idle_started_at.is_some() + } + + /// Feed one detection; returns the newly published state when it changed. + pub fn update(&mut self, detection: Detection, now: Instant) -> Option { + if detection.skip_state_update { + return None; + } + + let next = detection.state; + let holds = self.published == Some(AgentState::Working) + && next == AgentState::Idle + && !detection.visible_idle + && !detection.visible_blocker; + + if holds { + match self.pending_idle_started_at { + None => { + self.pending_idle_started_at = Some(now); + self.pending_idle_confirmations = 0; + return None; + } + Some(started_at) => { + if now.duration_since(started_at) < Self::HOLD_CAP { + self.pending_idle_confirmations = + self.pending_idle_confirmations.saturating_add(1); + if self.pending_idle_confirmations < Self::CONFIRMATIONS { + return None; + } + } + } + } + } + self.pending_idle_started_at = None; + self.pending_idle_confirmations = 0; + + if self.published == Some(next) { + return None; + } + self.published = Some(next); + Some(next) + } + + /// The agent process is gone; the terminal is a plain shell again. + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +pub mod session_discovery { + //! Locates the on-disk session an agent CLI is writing, so the terminal + //! thread can be resumed after a restart. Purely correlational (newest + //! session file for the working directory, modified since the agent + //! started) — no agent configuration is touched. + + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::SystemTime; + + use super::AgentKind; + + /// Claude Code stores transcripts under + /// `~/.claude/projects//.jsonl`, where the munged + /// directory name replaces every non-alphanumeric character with `-`. + pub fn claude_project_dir_name(cwd: &Path) -> String { + cwd.to_string_lossy() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect() + } + + /// `current_session` is the session previously captured for this + /// terminal; when its file is still being written it is preferred over + /// the globally newest one, so two agents sharing a working directory + /// don't steal each other's sessions on every capture. + pub fn find_session( + agent: AgentKind, + home_dir: &Path, + cwd: &Path, + since: SystemTime, + current_session: Option<&str>, + ) -> Option { + match agent { + AgentKind::ClaudeCode => find_claude_session(home_dir, cwd, since, current_session), + AgentKind::Codex => find_codex_session(home_dir, cwd, since, current_session), + } + } + + fn find_claude_session( + home_dir: &Path, + cwd: &Path, + since: SystemTime, + current_session: Option<&str>, + ) -> Option { + let project_dir = home_dir + .join(".claude") + .join("projects") + .join(claude_project_dir_name(cwd)); + + if let Some(current) = current_session { + let current_path = project_dir.join(format!("{current}.jsonl")); + if fs::metadata(current_path) + .and_then(|metadata| metadata.modified()) + .is_ok_and(|modified| modified >= since) + { + return Some(current.to_string()); + } + } + + let mut newest: Option<(SystemTime, String)> = None; + for entry in fs::read_dir(project_dir).ok()?.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "jsonl") { + continue; + } + let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let Ok(modified) = entry.metadata().and_then(|metadata| metadata.modified()) else { + continue; + }; + if modified < since { + continue; + } + if newest + .as_ref() + .is_none_or(|(newest_time, _)| modified > *newest_time) + { + newest = Some((modified, stem.to_string())); + } + } + newest.map(|(_, session_id)| session_id) + } + + /// Codex stores rollouts under + /// `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`; + /// the first line records the session's working directory. + fn find_codex_session( + home_dir: &Path, + cwd: &Path, + since: SystemTime, + current_session: Option<&str>, + ) -> Option { + let sessions_dir = home_dir.join(".codex").join("sessions"); + let mut candidates: Vec<(SystemTime, PathBuf)> = Vec::new(); + collect_recent_files(&sessions_dir, since, 3, &mut candidates); + candidates.sort_by(|(left, _), (right, _)| right.cmp(left)); + + if let Some(current) = current_session + && candidates.iter().any(|(_, path)| { + codex_session_id_from_filename(path).as_deref() == Some(current) + }) + { + return Some(current.to_string()); + } + + // The first line is session metadata JSON; matching the exact quoted + // value avoids capturing a sibling directory whose path merely starts + // with this one (/repo/app vs /repo/app2). + let quoted_cwd = serde_json_style_quoted(cwd.to_string_lossy().as_ref()); + for (_, path) in candidates.into_iter().take(20) { + let Ok(contents) = fs::File::open(&path).map(std::io::BufReader::new) else { + continue; + }; + use std::io::BufRead as _; + let Some(Ok(first_line)) = contents.lines().next() else { + continue; + }; + if !first_line.contains("ed_cwd) { + continue; + } + if let Some(session_id) = codex_session_id_from_filename(&path) { + return Some(session_id); + } + } + None + } + + /// The cwd as it appears as a JSON string value, including the closing + /// quote so prefix paths don't match. + fn serde_json_style_quoted(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('"'); + for character in value.chars() { + match character { + '"' => quoted.push_str("\\\""), + '\\' => quoted.push_str("\\\\"), + _ => quoted.push(character), + } + } + quoted.push('"'); + quoted + } + + fn collect_recent_files( + dir: &Path, + since: SystemTime, + depth: usize, + candidates: &mut Vec<(SystemTime, PathBuf)>, + ) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if depth > 0 { + collect_recent_files(&path, since, depth - 1, candidates); + } + continue; + } + if path.extension().is_none_or(|extension| extension != "jsonl") { + continue; + } + let Ok(modified) = entry.metadata().and_then(|metadata| metadata.modified()) else { + continue; + }; + if modified >= since { + candidates.push((modified, path)); + } + } + } + + fn codex_session_id_from_filename(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + // rollout-2026-07-21T22-15-03-: the uuid is the last 36 chars. + // Byte indexing would panic mid-codepoint on multibyte filenames, so + // slice only at a verified char boundary. + let split = stem.len().checked_sub(36)?; + if !stem.is_char_boundary(split) { + return None; + } + let candidate = &stem[split..]; + let is_uuid = candidate.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }); + is_uuid.then(|| candidate.to_string()) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::io::Write as _; + use std::time::Duration; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "agent_detect_test_{}_{}", + name, + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create temp dir"); + Self(path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn munges_cwd_like_claude_code() { + assert_eq!( + claude_project_dir_name(Path::new("/Users/foo/code/pay.kit_v2")), + "-Users-foo-code-pay-kit-v2" + ); + } + + #[test] + fn finds_newest_claude_transcript_modified_since_launch() { + let home = TempDir::new("claude"); + let cwd = Path::new("/repo/app"); + let project_dir = home + .0 + .join(".claude") + .join("projects") + .join(claude_project_dir_name(cwd)); + fs::create_dir_all(&project_dir).expect("create project dir"); + + let stale = project_dir.join("00000000-0000-0000-0000-000000000000.jsonl"); + fs::write(&stale, "{}").expect("write stale transcript"); + let since = SystemTime::now() + Duration::from_secs(1); + + assert_eq!( + find_session(AgentKind::ClaudeCode, &home.0, cwd, since, None), + None, + "a transcript older than the launch must not be picked up" + ); + + let live = project_dir.join("11111111-2222-3333-4444-555555555555.jsonl"); + fs::write(&live, "{}").expect("write live transcript"); + let since = SystemTime::now() - Duration::from_secs(60); + assert_eq!( + find_session(AgentKind::ClaudeCode, &home.0, cwd, since, None).as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); + } + + #[test] + fn prefers_current_claude_session_still_being_written() { + let home = TempDir::new("claude_sticky"); + let cwd = Path::new("/repo/app"); + let project_dir = home + .0 + .join(".claude") + .join("projects") + .join(claude_project_dir_name(cwd)); + fs::create_dir_all(&project_dir).expect("create project dir"); + + let mine = "11111111-2222-3333-4444-555555555555"; + let other = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + fs::write(project_dir.join(format!("{mine}.jsonl")), "{}").expect("write mine"); + fs::write(project_dir.join(format!("{other}.jsonl")), "{}").expect("write other"); + + let since = SystemTime::now() - Duration::from_secs(60); + assert_eq!( + find_session(AgentKind::ClaudeCode, &home.0, cwd, since, Some(mine)).as_deref(), + Some(mine), + "an actively-written current session must not be replaced by a newer sibling" + ); + } + + #[test] + fn codex_cwd_match_requires_exact_quoted_value() { + let home = TempDir::new("codex_prefix"); + let cwd = Path::new("/repo/app"); + let day_dir = home + .0 + .join(".codex") + .join("sessions") + .join("2026") + .join("07") + .join("21"); + fs::create_dir_all(&day_dir).expect("create sessions dir"); + + let sibling = day_dir + .join("rollout-2026-07-21T12-00-00-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"); + let mut file = fs::File::create(&sibling).expect("create sibling rollout"); + writeln!(file, r#"{{"type":"session_meta","cwd":"/repo/app2"}}"#).expect("write"); + + let since = SystemTime::now() - Duration::from_secs(60); + assert_eq!( + find_session(AgentKind::Codex, &home.0, cwd, since, None), + None, + "/repo/app must not match a session recorded for /repo/app2" + ); + } + + #[test] + fn codex_session_id_ignores_multibyte_filenames_without_panicking() { + assert_eq!( + codex_session_id_from_filename(Path::new("rollout-ünïcödé-😀😀😀😀😀😀😀.jsonl")), + None + ); + } + + #[test] + fn finds_codex_rollout_matching_cwd() { + let home = TempDir::new("codex"); + let cwd = Path::new("/repo/app"); + let day_dir = home + .0 + .join(".codex") + .join("sessions") + .join("2026") + .join("07") + .join("21"); + fs::create_dir_all(&day_dir).expect("create sessions dir"); + + let other = day_dir + .join("rollout-2026-07-21T10-00-00-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"); + let mut file = fs::File::create(&other).expect("create other rollout"); + writeln!(file, r#"{{"type":"session_meta","cwd":"/elsewhere"}}"#).expect("write"); + + let matching = day_dir + .join("rollout-2026-07-21T11-00-00-12345678-9abc-def0-1234-56789abcdef0.jsonl"); + let mut file = fs::File::create(&matching).expect("create matching rollout"); + writeln!(file, r#"{{"type":"session_meta","cwd":"/repo/app"}}"#).expect("write"); + + let since = SystemTime::now() - Duration::from_secs(60); + assert_eq!( + find_session(AgentKind::Codex, &home.0, cwd, since, None).as_deref(), + Some("12345678-9abc-def0-1234-56789abcdef0") + ); + } + } +} + +struct Rule { + state: AgentState, + priority: i32, + region: Region, + visible_idle: bool, + visible_blocker: bool, + visible_working: bool, + skip_state_update: bool, + gate: Gate, +} + +impl Rule { + fn new(state: AgentState, priority: i32, region: Region) -> Self { + Self { + state, + priority, + region, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + gate: Gate::default(), + } + } + + fn visible(mut self) -> Self { + match self.state { + AgentState::Idle => self.visible_idle = true, + AgentState::Blocked => self.visible_blocker = true, + AgentState::Working => self.visible_working = true, + AgentState::Unknown => {} + } + self + } + + fn skip_update(mut self) -> Self { + self.skip_state_update = true; + self + } + + fn gate(mut self, gate: Gate) -> Self { + self.gate = gate; + self + } +} + +#[derive(Default)] +struct Gate { + contains: Vec<&'static str>, + regex: Vec, + line_regex: Vec, + all: Vec, + any: Vec, + not: Vec, +} + +impl Gate { + fn contains(needles: &[&'static str]) -> Self { + Self { + contains: needles.to_vec(), + ..Default::default() + } + } + + fn regex(patterns: &[&str]) -> Self { + Self { + regex: compile(patterns), + ..Default::default() + } + } + + fn line_regex(patterns: &[&str]) -> Self { + Self { + line_regex: compile(patterns), + ..Default::default() + } + } + + fn with_any(mut self, any: Vec) -> Self { + self.any = any; + self + } + + fn with_all(mut self, all: Vec) -> Self { + self.all = all; + self + } + + fn with_not(mut self, not: Vec) -> Self { + self.not = not; + self + } +} + +fn compile(patterns: &[&str]) -> Vec { + patterns + .iter() + .map(|pattern| { + #[allow(clippy::unwrap_used)] + Regex::new(pattern).unwrap() + }) + .collect() +} + +/// Every matcher on a gate must hold: all `contains` substrings present +/// (case-insensitive), all `regex` matching the region, each `line_regex` +/// matching at least one line, all `all` sub-gates matching, at least one +/// `any` sub-gate matching (when non-empty), and no `not` sub-gate matching. +fn gate_matches(gate: &Gate, text: &str, lower_text: &str) -> bool { + gate.contains + .iter() + .all(|needle| lower_text.contains(needle)) + && gate.regex.iter().all(|regex| regex.is_match(text)) + && gate + .line_regex + .iter() + .all(|regex| text.lines().any(|line| regex.is_match(line))) + && gate + .all + .iter() + .all(|nested| gate_matches(nested, text, lower_text)) + && (gate.any.is_empty() + || gate + .any + .iter() + .any(|nested| gate_matches(nested, text, lower_text))) + && !gate + .not + .iter() + .any(|nested| gate_matches(nested, text, lower_text)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Region { + OscTitle, + WholeRecent, + BottomNonEmptyLines(usize), + /// Everything after the last `─...` horizontal-rule line — the live + /// portion of a boxed TUI below its last divider. + AfterLastHorizontalRule, + /// The body of the input box: the lines between the last two + /// horizontal-rule borders. + PromptBoxBody, + /// Everything after the last codex `›` prompt line. + AfterLastPromptMarker, +} + +fn slice_region<'a>(input: DetectionInput<'a>, region: Region) -> &'a str { + let content = input.screen; + match region { + Region::OscTitle => input.osc_title, + Region::WholeRecent => content, + Region::BottomNonEmptyLines(count) => bottom_non_empty_lines(content, count), + Region::AfterLastHorizontalRule => after_last_horizontal_rule(content), + Region::PromptBoxBody => prompt_box_body(content).unwrap_or(""), + Region::AfterLastPromptMarker => after_last_prompt_marker(content), + } +} + +fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize { + lines[..index.min(lines.len())] + .iter() + .map(|line| line.len() + 1) + .sum::() + .min(content.len()) +} + +fn bottom_non_empty_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(start_index) = lines + .iter() + .enumerate() + .rev() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + &content[line_start_offset(content, &lines, start_index)..] +} + +fn after_last_horizontal_rule(content: &str) -> &str { + let mut last_rule_end = 0usize; + let mut offset = 0usize; + for line in content.lines() { + let next_offset = offset + line.len() + 1; + if is_horizontal_rule(line) { + last_rule_end = next_offset.min(content.len()); + } + offset = next_offset; + } + &content[last_rule_end..] +} + +fn prompt_box_body(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let top = prompt_box_top_border_index(&lines)?; + let start = line_start_offset(content, &lines, top + 1); + let end_index = lines[top + 1..] + .iter() + .position(|line| is_horizontal_rule(line)) + .map(|relative| top + 1 + relative) + .unwrap_or(lines.len()); + let end = line_start_offset(content, &lines, end_index); + Some(&content[start..end.max(start)]) +} + +fn prompt_box_top_border_index(lines: &[&str]) -> Option { + let mut border_count = 0; + for index in (0..lines.len()).rev() { + if is_horizontal_rule(lines[index]) { + border_count += 1; + if border_count == 2 { + return Some(index); + } + } + } + None +} + +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count(); + if rule_chars == 0 { + return false; + } + let rule_bytes = trimmed + .char_indices() + .nth(rule_chars) + .map(|(index, _)| index) + .unwrap_or(trimmed.len()); + let suffix = trimmed[rule_bytes..].trim_start(); + suffix.is_empty() || rule_chars >= 3 +} + +fn after_last_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = lines + .iter() + .rposition(|line| *line == "›" || line.starts_with("› ")) + else { + return content; + }; + &content[line_start_offset(content, &lines, index + 1)..] +} + +fn rules_for(agent: AgentKind) -> &'static [Rule] { + match agent { + AgentKind::ClaudeCode => claude_rules(), + AgentKind::Codex => codex_rules(), + } +} + +fn claude_rules() -> &'static [Rule] { + static RULES: LazyLock> = LazyLock::new(|| { + vec![ + // A Braille spinner glyph leading the OSC title means a turn is + // in flight. + Rule::new(AgentState::Working, 1100, Region::OscTitle) + .visible() + .gate(Gate::regex(&[r"^[\x{2800}-\x{28FF}] "])), + // Transcript viewer overlay: scrollback text, never a state + // signal. + Rule::new(AgentState::Unknown, 1000, Region::BottomNonEmptyLines(3)) + .skip_update() + .gate( + Gate::contains(&["showing detailed transcript"]).with_any(vec![ + Gate::contains(&["ctrl+o", "to toggle"]), + Gate::contains(&["ctrl+e", "show all"]), + Gate::contains(&["ctrl+e", "collapse"]), + Gate::contains(&["↑↓ scroll"]), + Gate::contains(&["? for shortcuts"]), + ]), + ), + // A live selection form (tool confirmation, elicitation, ...) + // below the last divider. + Rule::new(AgentState::Blocked, 980, Region::AfterLastHorizontalRule) + .visible() + .gate( + Gate::contains(&["enter to select", "esc to cancel"]).with_any(vec![ + Gate::contains(&["tab/arrow keys to navigate"]), + Gate::contains(&["arrow keys to navigate"]), + Gate::contains(&["arrows to navigate"]), + Gate::contains(&["↑/↓ to navigate"]), + Gate::contains(&["↑↓ to navigate"]), + ]), + ), + // An empty input box (bare `❯` between borders) is a resting + // prompt — unless the same box carries selection-form hints. + Rule::new(AgentState::Idle, 950, Region::PromptBoxBody) + .visible() + .gate(Gate::line_regex(&[r"^\s*❯"]).with_not(vec![ + Gate::contains(&["enter to select"]), + Gate::contains(&["esc to cancel"]), + Gate::contains(&["tab/arrow keys"]), + Gate::contains(&["arrow keys to navigate"]), + Gate::contains(&["↑/↓ to navigate"]), + ])), + // The model picker is browsing, not a permission request. + Rule::new(AgentState::Unknown, 900, Region::WholeRecent) + .skip_update() + .gate( + Gate::contains(&["select model", "enter to set as default", "esc to cancel"]) + .with_not(vec![ + Gate::contains(&["do you want to proceed?"]), + Gate::contains(&["enter to select"]), + ]), + ), + // Startup folder-trust dialog blocks everything until answered. + Rule::new(AgentState::Blocked, 860, Region::WholeRecent) + .visible() + .gate( + Gate::contains(&["do you trust the files in this folder?"]).with_all(vec![ + Gate::default().with_any(vec![ + Gate::line_regex(&[r"(?i)^\s*❯?\s*1\.\s*yes"]), + Gate::contains(&["enter to confirm"]), + ]), + ]), + ), + // Bash permission prompt with a numbered yes/no menu. + Rule::new(AgentState::Blocked, 850, Region::WholeRecent) + .visible() + .gate( + Gate::contains(&["do you want to proceed?"]) + .with_any(vec![ + Gate::contains(&["bash command"]), + Gate::contains(&["bash("]), + Gate::contains(&["contains expansion"]), + Gate::contains(&["tab to amend"]), + Gate::contains(&["ctrl+e to explain"]), + ]) + .with_all(vec![Gate::default().with_any(vec![ + Gate::line_regex(&[r"(?i)^\s*❯?\s*yes\b"]), + Gate::line_regex(&[r"(?i)^\s*1\.\s*yes\b"]), + Gate::line_regex(&[r"(?i)^\s*2\.\s*no\b"]), + ])]), + ), + // Non-bash permission prompt below the last divider. + Rule::new(AgentState::Blocked, 840, Region::AfterLastHorizontalRule) + .visible() + .gate( + Gate::contains(&["do you want to proceed?", "esc to cancel"]).with_all(vec![ + Gate::default().with_any(vec![ + Gate::line_regex(&[r"(?i)^\s*❯?\s*1\.\s*yes\b"]), + Gate::line_regex(&[r"(?i)^\s*2\.\s*yes\b"]), + Gate::line_regex(&[r"(?i)^\s*2\.\s*no\b"]), + Gate::line_regex(&[r"(?i)^\s*3\.\s*no\b"]), + ]), + ]), + ), + // Older / uncommon confirmation phrasings anywhere on screen, as + // long as no empty prompt line proves the agent already moved on. + Rule::new(AgentState::Blocked, 300, Region::WholeRecent).gate( + Gate::default() + .with_any(vec![ + Gate::contains(&["do you want to"]).with_any(vec![ + Gate::contains(&["yes"]), + Gate::contains(&["❯"]), + ]), + Gate::contains(&["would you like to"]).with_any(vec![ + Gate::contains(&["yes"]), + Gate::contains(&["❯"]), + ]), + Gate::contains(&["waiting for permission"]), + Gate::contains(&["do you want to allow this connection?"]), + Gate::contains(&["tab to amend"]), + Gate::contains(&["ctrl+e to explain"]), + Gate::contains(&["do you want to proceed?", "esc to cancel"]), + ]) + .with_not(vec![Gate::regex(&[r"(?m)^\s*❯\s*$"])]), + ), + // Screen fallback for terminals whose OSC title isn't forwarded: + // the live status line during generation offers esc to interrupt. + Rule::new(AgentState::Working, 500, Region::BottomNonEmptyLines(6)) + .visible() + .gate(Gate::contains(&["esc to interrupt"])), + // ✳ leading the OSC title is the resting sparkle. + Rule::new(AgentState::Idle, 250, Region::OscTitle) + .visible() + .gate(Gate::regex(&[r"^\x{2733} "])), + ] + }); + &RULES +} + +fn codex_rules() -> &'static [Rule] { + static RULES: LazyLock> = LazyLock::new(|| { + vec![ + Rule::new(AgentState::Blocked, 1100, Region::OscTitle) + .visible() + .gate(Gate::contains(&["action required"])), + Rule::new(AgentState::Working, 1050, Region::OscTitle) + .visible() + .gate(Gate::regex(&["(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)"])), + Rule::new(AgentState::Unknown, 1000, Region::AfterLastPromptMarker) + .skip_update() + .gate( + Gate::contains(&[ + "↑/↓ to scroll", + "pgup/pgdn to", + "home/end to jump", + "q to quit", + ]) + .with_any(vec![ + Gate::contains(&["esc to edit prev"]), + Gate::contains(&["esc/← to edit prev"]), + ]), + ), + Rule::new(AgentState::Blocked, 900, Region::AfterLastPromptMarker) + .visible() + .gate(Gate::default().with_any(vec![ + Gate::contains(&["press enter to confirm or esc to cancel"]), + Gate::contains(&["enter to submit answer"]), + Gate::contains(&["enter to submit all"]), + Gate::contains(&["allow command?"]), + ])), + Rule::new(AgentState::Blocked, 600, Region::WholeRecent).gate( + Gate::default().with_any(vec![ + Gate::contains(&["[y/n]"]), + Gate::contains(&["yes (y)"]), + Gate::contains(&["do you want to"]).with_any(vec![ + Gate::contains(&["yes"]), + Gate::contains(&["❯"]), + ]), + Gate::contains(&["would you like to"]).with_any(vec![ + Gate::contains(&["yes"]), + Gate::contains(&["❯"]), + ]), + ]), + ), + Rule::new(AgentState::Working, 500, Region::BottomNonEmptyLines(3)) + .visible() + .gate( + Gate::line_regex(&[ + r"^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$", + ]) + .with_not(vec![Gate::contains(&["■ conversation interrupted"])]), + ), + // Any static, non-spinner title means the last turn finished. + Rule::new(AgentState::Idle, 100, Region::OscTitle) + .visible() + .gate(Gate::regex(&[r"\S"]).with_not(vec![ + Gate::regex(&["(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)"]), + Gate::contains(&["action required"]), + ])), + ] + }); + &RULES +} + +#[cfg(test)] +mod tests { + use super::*; + + fn detect_screen(agent: AgentKind, screen: &str) -> Detection { + detect( + agent, + DetectionInput { + screen, + osc_title: "", + }, + ) + } + + fn detect_title(agent: AgentKind, osc_title: &str) -> Detection { + detect( + agent, + DetectionInput { + screen: "", + osc_title, + }, + ) + } + + #[test] + fn identifies_agents_from_command_names() { + assert_eq!( + AgentKind::from_command_name("claude"), + Some(AgentKind::ClaudeCode) + ); + assert_eq!( + AgentKind::from_command_name("/usr/local/bin/claude"), + Some(AgentKind::ClaudeCode) + ); + assert_eq!(AgentKind::from_command_name("codex"), Some(AgentKind::Codex)); + assert_eq!(AgentKind::from_command_name("CODEX.exe"), Some(AgentKind::Codex)); + assert_eq!(AgentKind::from_command_name("zsh"), None); + assert_eq!(AgentKind::from_command_name("node"), None); + } + + #[test] + fn claude_spinner_title_is_working() { + let detection = detect_title(AgentKind::ClaudeCode, "⠧ Reticulating…"); + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + } + + #[test] + fn claude_sparkle_title_is_idle() { + let detection = detect_title(AgentKind::ClaudeCode, "✳ claude"); + assert_eq!(detection.state, AgentState::Idle); + assert!(detection.visible_idle); + } + + #[test] + fn claude_empty_prompt_box_is_idle() { + let screen = "\ +Some earlier output +────────────────────────────── + ❯ +────────────────────────────── + ? for shortcuts"; + let detection = detect_screen(AgentKind::ClaudeCode, screen); + assert_eq!(detection.state, AgentState::Idle); + assert!(detection.visible_idle); + } + + #[test] + fn claude_selection_form_is_blocked() { + let screen = "\ +────────────────────────────── + Do you want to make this edit? + ❯ 1. Yes + 2. No + Enter to select · Esc to cancel · Tab/arrow keys to navigate"; + let detection = detect_screen(AgentKind::ClaudeCode, screen); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn claude_bash_permission_prompt_is_blocked() { + let screen = "\ + Bash command + cargo build + Do you want to proceed? + ❯ 1. Yes + 2. No, and tell Claude what to do differently"; + let detection = detect_screen(AgentKind::ClaudeCode, screen); + assert_eq!(detection.state, AgentState::Blocked); + } + + #[test] + fn claude_prompt_box_with_selection_hints_is_not_idle() { + let screen = "\ +────────────────────────────── + ❯ 1. Yes + Enter to select · Esc to cancel · arrow keys to navigate +──────────────────────────────"; + let detection = detect_screen(AgentKind::ClaudeCode, screen); + assert!(!detection.visible_idle); + } + + #[test] + fn claude_transcript_viewer_skips_state_update() { + let screen = "\ +old scrollback with Do you want to proceed? +Showing detailed transcript · ctrl+o to toggle"; + let detection = detect_screen(AgentKind::ClaudeCode, screen); + assert!(detection.skip_state_update); + } + + #[test] + fn claude_folder_trust_dialog_is_blocked() { + let screen = "\ + Do you trust the files in this folder? + /Users/foo/repo + ❯ 1. Yes, proceed + 2. No, exit"; + let detection = detect_screen(AgentKind::ClaudeCode, screen); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn claude_esc_to_interrupt_line_is_working_without_osc_title() { + let screen = "\ +some output +✳ Shimmying… (2s · esc to interrupt)"; + let detection = detect_screen(AgentKind::ClaudeCode, screen); + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + } + + #[test] + fn claude_unrecognized_screen_falls_back_to_idle() { + let detection = detect_screen(AgentKind::ClaudeCode, "plain shell output\n$ "); + assert_eq!(detection.state, AgentState::Idle); + assert!(!detection.visible_idle); + } + + #[test] + fn codex_action_required_title_is_blocked() { + let detection = detect_title(AgentKind::Codex, "Action Required · codex"); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn codex_spinner_title_is_working() { + let detection = detect_title(AgentKind::Codex, "⠹ codex"); + assert_eq!(detection.state, AgentState::Working); + } + + #[test] + fn codex_static_title_is_idle() { + let detection = detect_title(AgentKind::Codex, "codex — done"); + assert_eq!(detection.state, AgentState::Idle); + assert!(detection.visible_idle); + } + + #[test] + fn codex_working_status_line_is_working() { + let screen = "\ +› do the thing +• Working (12s · esc to interrupt)"; + let detection = detect_screen(AgentKind::Codex, screen); + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + } + + #[test] + fn codex_interrupted_working_line_is_not_working() { + let screen = "\ +■ Conversation interrupted +• Working (12s · esc to interrupt)"; + let detection = detect_screen(AgentKind::Codex, screen); + assert_ne!(detection.state, AgentState::Working); + } + + #[test] + fn codex_allow_command_after_prompt_is_blocked() { + let screen = "\ +› run the migration + Allow command? + Press enter to confirm or esc to cancel"; + let detection = detect_screen(AgentKind::Codex, screen); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn codex_stale_blocker_before_prompt_marker_is_ignored() { + // The confirmation text sits above the newest `›` prompt line, so it + // belongs to a finished exchange. + let screen = "\ + Allow command? + Press enter to confirm or esc to cancel +› "; + let detection = detect_screen(AgentKind::Codex, screen); + assert_ne!(detection.state, AgentState::Blocked); + } + + #[test] + fn tracker_holds_working_to_idle_until_confirmed() { + let mut tracker = StatusTracker::default(); + let now = Instant::now(); + let working = Detection { + state: AgentState::Working, + visible_idle: false, + visible_blocker: false, + visible_working: true, + skip_state_update: false, + }; + let plain_idle = Detection { + state: AgentState::Idle, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + }; + + assert_eq!(tracker.update(working, now), Some(AgentState::Working)); + assert_eq!(tracker.update(plain_idle, now), None); + assert_eq!( + tracker.update(plain_idle, now + Duration::from_millis(100)), + None + ); + assert_eq!( + tracker.update(plain_idle, now + Duration::from_millis(200)), + None + ); + assert_eq!( + tracker.update(plain_idle, now + Duration::from_millis(300)), + Some(AgentState::Idle) + ); + } + + #[test] + fn tracker_publishes_visible_idle_immediately() { + let mut tracker = StatusTracker::default(); + let now = Instant::now(); + let working = Detection { + state: AgentState::Working, + visible_idle: false, + visible_blocker: false, + visible_working: true, + skip_state_update: false, + }; + let visible_idle = Detection { + state: AgentState::Idle, + visible_idle: true, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + }; + + assert_eq!(tracker.update(working, now), Some(AgentState::Working)); + assert_eq!(tracker.update(visible_idle, now), Some(AgentState::Idle)); + } + + #[test] + fn tracker_publishes_blocked_immediately() { + let mut tracker = StatusTracker::default(); + let now = Instant::now(); + let working = Detection { + state: AgentState::Working, + visible_idle: false, + visible_blocker: false, + visible_working: true, + skip_state_update: false, + }; + let blocked = Detection { + state: AgentState::Blocked, + visible_idle: false, + visible_blocker: true, + visible_working: false, + skip_state_update: false, + }; + + assert_eq!(tracker.update(working, now), Some(AgentState::Working)); + assert_eq!(tracker.update(blocked, now), Some(AgentState::Blocked)); + } + + #[test] + fn tracker_ignores_skip_state_update() { + let mut tracker = StatusTracker::default(); + let now = Instant::now(); + let working = Detection { + state: AgentState::Working, + visible_idle: false, + visible_blocker: false, + visible_working: true, + skip_state_update: false, + }; + let skip = Detection { + state: AgentState::Unknown, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: true, + }; + + assert_eq!(tracker.update(working, now), Some(AgentState::Working)); + assert_eq!(tracker.update(skip, now), None); + assert_eq!(tracker.published_state(), Some(AgentState::Working)); + } + + #[test] + fn tracker_cap_expires_the_idle_hold() { + let mut tracker = StatusTracker::default(); + let now = Instant::now(); + let working = Detection { + state: AgentState::Working, + visible_idle: false, + visible_blocker: false, + visible_working: true, + skip_state_update: false, + }; + let plain_idle = Detection { + state: AgentState::Idle, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + }; + + assert_eq!(tracker.update(working, now), Some(AgentState::Working)); + assert_eq!(tracker.update(plain_idle, now), None); + assert_eq!( + tracker.update(plain_idle, now + Duration::from_millis(800)), + Some(AgentState::Idle) + ); + } + + #[test] + fn resume_argv_keeps_session_ref_as_data() { + let argv = AgentKind::ClaudeCode.resume_argv("abc; rm -rf /"); + assert_eq!(argv, vec!["claude", "--resume", "abc; rm -rf /"]); + let argv = AgentKind::Codex.resume_argv("0197-abc"); + assert_eq!(argv, vec!["codex", "resume", "0197-abc"]); + } +} diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 708d1c716874f4..4a9896e432920d 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -1,6 +1,6 @@ use acp_thread::{ AgentConnection, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, - AgentSessionListResponse, + AgentSessionListResponse, ElicitationStore, }; use action_log::ActionLog; use agent_client_protocol::schema::{ @@ -27,7 +27,11 @@ use std::path::PathBuf; use std::process::{ExitStatus, Stdio}; use std::rc::Rc; use std::sync::{Arc, Mutex}; -use std::{any::Any, cell::RefCell, collections::VecDeque}; +use std::{ + any::Any, + cell::{Cell, RefCell}, + collections::VecDeque, +}; use task::{Shell, ShellBuilder, SpawnInTerminal}; use thiserror::Error; use util::ResultExt as _; @@ -269,6 +273,7 @@ impl FlattenAcpResult for Result, anyhow::Error> { struct ClientContext { sessions: Rc>>, session_list: Rc>>>, + request_elicitations: Entity, } fn dispatch_queue_closed_error() -> acp::Error { @@ -394,8 +399,10 @@ pub struct AcpConnection { auth_methods: Vec, agent_server_store: WeakEntity, agent_capabilities: acp::AgentCapabilities, + request_elicitations: Entity, defaults: AcpConnectionDefaults, child: Option, + server_exited: Rc>, session_list: Option>, debug_log: AcpDebugLog, _settings_subscription: Subscription, @@ -726,11 +733,19 @@ fn connect_client_future( on_request!(handle_wait_for_terminal_exit), agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + on_request!(handle_create_elicitation), + agent_client_protocol::on_receive_request!(), + ) // --- Notification handlers (agent→client) --- .on_receive_notification( on_notification!(handle_session_notification), agent_client_protocol::on_receive_notification!(), ) + .on_receive_notification( + on_notification!(handle_complete_elicitation), + agent_client_protocol::on_receive_notification!(), + ) .connect_with( transport, move |connection: ConnectionTo| async move { @@ -743,10 +758,7 @@ fn connect_client_future( ) } -fn client_capabilities_for_agent( - agent_id: &AgentId, - supports_boolean_config_options: bool, -) -> acp::ClientCapabilities { +fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities { let mut meta = acp::Meta::from_iter([ ("terminal_output".into(), true.into()), ("terminal-auth".into(), true.into()), @@ -756,24 +768,24 @@ fn client_capabilities_for_agent( meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into()); } - let mut capabilities = acp::ClientCapabilities::new() + acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) .terminal(true) .auth(acp::AuthCapabilities::new().terminal(true)) - .meta(meta); - - if supports_boolean_config_options { - capabilities = capabilities.session( + .session( acp::ClientSessionCapabilities::new().config_options( acp::SessionConfigOptionsCapabilities::new() .boolean(acp::BooleanConfigOptionCapabilities::new()), ), - ); - } - - capabilities + ) + .elicitation( + acp::ElicitationCapabilities::new() + .form(acp::ElicitationFormCapabilities::new()) + .url(acp::ElicitationUrlCapabilities::new()), + ) + .meta(meta) } impl AcpConnection { @@ -861,6 +873,7 @@ impl AcpConnection { let client_session_list: Rc>>> = Rc::new(RefCell::new(None)); + let request_elicitations = cx.new(|_| ElicitationStore::default()); // Set up the foreground dispatch channel for bridging Send handler // closures to the !Send foreground thread. @@ -950,6 +963,7 @@ impl AcpConnection { let dispatch_context = ClientContext { sessions: sessions.clone(), session_list: client_session_list.clone(), + request_elicitations: request_elicitations.clone(), }; let dispatch_task = cx.spawn({ let mut dispatch_rx = dispatch_rx; @@ -963,10 +977,7 @@ impl AcpConnection { let initialize_response = connection .send_request( acp::InitializeRequest::new(ProtocolVersion::V1) - .client_capabilities(client_capabilities_for_agent( - &agent_id, - cx.update(|cx| cx.has_flag::()), - )) + .client_capabilities(client_capabilities_for_agent(&agent_id)) .client_info( acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), @@ -999,10 +1010,13 @@ impl AcpConnection { return Err(UnsupportedVersion.into()); } + let server_exited = Rc::new(Cell::new(false)); let wait_task = cx.spawn({ let sessions = sessions.clone(); + let server_exited = server_exited.clone(); async move |cx| { let load_error = status_fut.await?; + server_exited.set(true); emit_load_error_to_all_sessions(&sessions, load_error, cx); anyhow::Ok(()) } @@ -1075,7 +1089,9 @@ impl AcpConnection { sessions, pending_sessions: Rc::new(RefCell::new(HashMap::default())), agent_capabilities: response.agent_capabilities, + request_elicitations, defaults, + server_exited, session_list, debug_log, _settings_subscription: settings_subscription, @@ -1096,6 +1112,7 @@ impl AcpConnection { connection: ConnectionTo, sessions: Rc>>, agent_capabilities: acp::AgentCapabilities, + request_elicitations: Entity, agent_server_store: WeakEntity, io_task: Task<()>, dispatch_task: Task<()>, @@ -1115,8 +1132,10 @@ impl AcpConnection { auth_methods: vec![], agent_server_store, agent_capabilities, + request_elicitations, defaults, child: None, + server_exited: Rc::new(Cell::new(false)), session_list: None, debug_log: AcpDebugLog::default(), _settings_subscription: settings_subscription, @@ -1279,7 +1298,6 @@ impl AcpConnection { cx: &mut AsyncApp, ) { let id = self.id.clone(); - let apply_boolean_defaults = cx.update(|cx| cx.has_flag::()); let defaults_to_apply: Vec<_> = { let config_opts_ref = config_options.borrow(); config_opts_ref @@ -1312,9 +1330,6 @@ impl AcpConnection { _ => None, } } - acp::SessionConfigKind::Boolean(_) if !apply_boolean_defaults => { - return None; - } acp::SessionConfigKind::Boolean(_) => default_value .as_bool() .map(acp::SessionConfigOptionValue::boolean), @@ -1574,6 +1589,10 @@ impl AgentConnection for AcpConnection { self.agent_version.clone() } + fn server_alive(&self) -> bool { + !self.server_exited.get() + } + fn new_session( self: Rc, project: Entity, @@ -1993,6 +2012,10 @@ impl AgentConnection for AcpConnection { self.connection.send_notification(params).log_err(); } + fn request_elicitations(&self) -> Option> { + Some(self.request_elicitations.clone()) + } + fn session_modes( &self, session_id: &acp::SessionId, @@ -2073,6 +2096,10 @@ pub mod test_support { load_session_count: Arc, close_session_count: Arc, fail_next_prompt: Arc, + auth_elicitation_request: Arc>>, + auth_elicitation_response: + Arc>>>, + auth_elicitation_completion: Arc>>, exit_status_sender: Arc>>>, } @@ -2105,6 +2132,23 @@ pub mod test_support { pub fn fail_next_prompt(&self) { self.fail_next_prompt.store(true, Ordering::SeqCst); } + + pub fn request_elicitation_during_auth( + &self, + request: acp::CreateElicitationRequest, + ) -> async_channel::Receiver { + let (response_tx, response_rx) = async_channel::bounded(1); + *self + .auth_elicitation_request + .lock() + .expect("auth elicitation request lock should not be poisoned") = Some(request); + *self + .auth_elicitation_response + .lock() + .expect("auth elicitation response lock should not be poisoned") = + Some(response_tx); + response_rx + } } impl crate::AgentServer for FakeAcpAgentServer { @@ -2125,6 +2169,9 @@ pub mod test_support { let load_session_count = self.load_session_count.clone(); let close_session_count = self.close_session_count.clone(); let fail_next_prompt = self.fail_next_prompt.clone(); + let auth_elicitation_request = self.auth_elicitation_request.clone(); + let auth_elicitation_response = self.auth_elicitation_response.clone(); + let auth_elicitation_completion = self.auth_elicitation_completion.clone(); let exit_status_sender = self.exit_status_sender.clone(); cx.spawn(async move |cx| { let harness = build_fake_acp_connection( @@ -2132,6 +2179,9 @@ pub mod test_support { load_session_count, close_session_count, fail_next_prompt, + auth_elicitation_request, + auth_elicitation_response, + auth_elicitation_completion, cx, ) .await?; @@ -2142,6 +2192,7 @@ pub mod test_support { let connection = harness.connection.clone(); let simulate_exit_task = cx.spawn(async move |cx| { while let Ok(status) = exit_rx.recv().await { + connection.server_exited.set(true); emit_load_error_to_all_sessions( &connection.sessions, LoadError::Exited { @@ -2193,6 +2244,10 @@ pub mod test_support { self.inner.agent_version() } + fn server_alive(&self) -> bool { + self.inner.server_alive() + } + fn new_session( self: Rc, project: Entity, @@ -2303,6 +2358,10 @@ pub mod test_support { self.inner.cancel(session_id, cx) } + fn request_elicitations(&self) -> Option> { + self.inner.request_elicitations() + } + fn truncate( &self, session_id: &acp::SessionId, @@ -2353,6 +2412,11 @@ pub mod test_support { load_session_count: Arc, close_session_count: Arc, fail_next_prompt: Arc, + auth_elicitation_request: Arc>>, + auth_elicitation_response: Arc< + Mutex>>, + >, + auth_elicitation_completion: Arc>>, cx: &mut AsyncApp, ) -> Result { let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); @@ -2382,8 +2446,41 @@ pub mod test_support { agent_client_protocol::on_receive_request!(), ) .on_receive_request( - async move |_req: acp::AuthenticateRequest, responder, _cx| { - responder.respond(Default::default()) + { + let auth_elicitation_request = auth_elicitation_request.clone(); + let auth_elicitation_response = auth_elicitation_response.clone(); + let auth_elicitation_completion = auth_elicitation_completion.clone(); + async move |_req: acp::AuthenticateRequest, responder, cx| { + let request = auth_elicitation_request + .lock() + .expect("auth elicitation request lock should not be poisoned") + .take(); + let response_tx = auth_elicitation_response + .lock() + .expect("auth elicitation response lock should not be poisoned") + .take(); + let completion = auth_elicitation_completion + .lock() + .expect("auth elicitation completion lock should not be poisoned") + .take(); + + if let Some(request) = request { + cx.send_request(request) + .on_receiving_result(async move |result| { + if let (Ok(response), Some(response_tx)) = (result, response_tx) + { + response_tx.send(response).await.ok(); + } + responder.respond(Default::default()) + })?; + if let Some(completion) = completion { + cx.send_notification(completion)?; + } + Ok(()) + } else { + responder.respond(Default::default()) + } + } }, agent_client_protocol::on_receive_request!(), ) @@ -2471,9 +2568,11 @@ pub mod test_support { let agent_capabilities = response.agent_capabilities; + let request_elicitations = cx.new(|_| ElicitationStore::default()); let dispatch_context = ClientContext { sessions: sessions.clone(), session_list: client_session_list.clone(), + request_elicitations: request_elicitations.clone(), }; let dispatch_task = cx.spawn({ let mut dispatch_rx = dispatch_rx; @@ -2492,6 +2591,7 @@ pub mod test_support { client_conn, sessions, agent_capabilities, + request_elicitations, agent_server_store, client_io_task, dispatch_task, @@ -2527,11 +2627,77 @@ pub mod test_support { Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), &mut cx.to_async(), ) .await .expect("failed to initialize ACP connection") } + + #[cfg(test)] + pub async fn connect_fake_acp_connection_with_auth_elicitation( + project: Entity, + request: acp::CreateElicitationRequest, + cx: &mut gpui::TestAppContext, + ) -> ( + FakeAcpConnectionHarness, + async_channel::Receiver, + ) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + }); + + let (response_tx, response_rx) = async_channel::bounded(1); + let harness = build_fake_acp_connection( + project, + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(Some(request))), + Arc::new(Mutex::new(Some(response_tx))), + Arc::new(Mutex::new(None)), + &mut cx.to_async(), + ) + .await + .expect("failed to initialize ACP connection"); + + (harness, response_rx) + } + + #[cfg(test)] + pub async fn connect_fake_acp_connection_with_auth_elicitation_completion( + project: Entity, + request: acp::CreateElicitationRequest, + completion: acp::CompleteElicitationNotification, + cx: &mut gpui::TestAppContext, + ) -> ( + FakeAcpConnectionHarness, + async_channel::Receiver, + ) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + }); + + let (response_tx, response_rx) = async_channel::bounded(1); + let harness = build_fake_acp_connection( + project, + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(Some(request))), + Arc::new(Mutex::new(Some(response_tx))), + Arc::new(Mutex::new(Some(completion))), + &mut cx.to_async(), + ) + .await + .expect("failed to initialize ACP connection"); + + (harness, response_rx) + } } #[cfg(test)] @@ -2542,9 +2708,341 @@ mod tests { use feature_flags::FeatureFlag as _; use settings::Settings as _; + fn init_feature_flags_test(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let mut settings_store = SettingsStore::test(cx); + settings_store.register_setting::(); + cx.set_global(settings_store); + cx.update_flags(false, vec![]); + }); + } + + #[gpui::test] + async fn test_server_alive_reflects_simulated_server_exit(cx: &mut gpui::TestAppContext) { + init_feature_flags_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + + let server = test_support::FakeAcpAgentServer::new(); + let delegate = crate::AgentServerDelegate::new( + project.read_with(cx, |project, _| project.agent_server_store().clone()), + None, + None, + ); + let connection = cx + .update(|cx| crate::AgentServer::connect(&server, delegate, project.clone(), cx)) + .await + .expect("fake ACP server should connect"); + + let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]); + let thread = cx + .update(|cx| connection.clone().new_session(project, work_dirs, cx)) + .await + .expect("session creation should succeed"); + cx.run_until_parked(); + + assert!( + connection.server_alive(), + "connection should report alive right after connecting" + ); + thread.read_with(cx, |thread, _| { + assert!( + thread.server_alive(), + "thread should report alive while the server is running" + ); + assert_eq!(thread.server_exit_status(), None); + }); + + server.simulate_server_exit(); + cx.run_until_parked(); + + assert!( + !connection.server_alive(), + "connection should report dead after the server process exits" + ); + thread.read_with(cx, |thread, _| { + assert!( + !thread.server_alive(), + "thread should report dead after the server process exits" + ); + assert!(thread.server_exit_status().is_some()); + }); + } + + #[gpui::test] + async fn client_capabilities_include_elicitation_without_acp_beta( + cx: &mut gpui::TestAppContext, + ) { + init_feature_flags_test(cx); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let elicitation = capabilities + .elicitation + .expect("elicitation should always be advertised"); + + assert!(elicitation.form.is_some()); + assert!(elicitation.url.is_some()); + } + + #[gpui::test] + async fn request_scoped_elicitation_during_auth_uses_connection_store( + cx: &mut gpui::TestAppContext, + ) { + init_feature_flags_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + + let request_id = acp::RequestId::Number(1); + let (harness, response_rx) = + test_support::connect_fake_acp_connection_with_auth_elicitation( + project, + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(request_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .await; + let connection = harness.connection.clone(); + let auth_task = + cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx)); + cx.run_until_parked(); + + let store = connection + .request_elicitations() + .expect("ACP connections expose request-scoped elicitations"); + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, request_id); + elicitation.id.clone() + }); + assert!( + connection.sessions.borrow().is_empty(), + "auth-time request-scoped elicitations must not require a session" + ); + + let expected_content = std::collections::BTreeMap::from([( + "name".to_string(), + acp::ElicitationContentValue::from("Ada"), + )]); + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content.clone()), + )), + cx, + ); + }); + + let response = response_rx + .recv() + .await + .expect("fake auth flow should receive elicitation response"); + assert_eq!( + response.action, + acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content) + ) + ); + auth_task.await.expect("auth should complete"); + } + + #[gpui::test] + async fn request_scoped_url_elicitation_completion_after_create_is_observed( + cx: &mut gpui::TestAppContext, + ) { + init_feature_flags_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + + let request_id = acp::RequestId::Number(1); + let url_elicitation_id = acp::ElicitationId::new("auth-url"); + let (harness, response_rx) = + test_support::connect_fake_acp_connection_with_auth_elicitation_completion( + project, + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(request_id.clone()), + url_elicitation_id.clone(), + "https://auth.example.com/device", + ), + "Authorize Zed in your browser", + ), + acp::CompleteElicitationNotification::new(url_elicitation_id), + cx, + ) + .await; + let connection = harness.connection.clone(); + let auth_task = + cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx)); + cx.run_until_parked(); + + let response = response_rx + .recv() + .await + .expect("fake auth flow should receive elicitation response"); + assert_eq!( + response.action, + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()) + ); + + let store = connection + .request_elicitations() + .expect("ACP connections expose request-scoped elicitations"); + store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, request_id); + assert!(matches!( + elicitation.status, + acp_thread::ElicitationStatus::Completed + )); + }); + + auth_task.await.expect("auth should complete"); + } + + #[gpui::test] + async fn request_scoped_elicitation_ignores_open_sessions(cx: &mut gpui::TestAppContext) { + init_feature_flags_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + + let request_id = acp::RequestId::Number(1); + let (harness, response_rx) = + test_support::connect_fake_acp_connection_with_auth_elicitation( + project.clone(), + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(request_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .await; + let connection = harness.connection.clone(); + let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]); + + let first_thread = cx + .update(|cx| { + connection.clone().load_session( + acp::SessionId::new("session-1"), + project.clone(), + work_dirs.clone(), + None, + cx, + ) + }) + .await + .expect("first load_session should succeed"); + let second_thread = cx + .update(|cx| { + connection.clone().load_session( + acp::SessionId::new("session-2"), + project, + work_dirs, + None, + cx, + ) + }) + .await + .expect("second load_session should succeed"); + cx.run_until_parked(); + assert_eq!( + connection.sessions.borrow().len(), + 2, + "test setup should have multiple open sessions" + ); + + let auth_task = + cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx)); + cx.run_until_parked(); + + let store = connection + .request_elicitations() + .expect("ACP connections expose request-scoped elicitations"); + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, request_id); + elicitation.id.clone() + }); + + for thread in [first_thread, second_thread] { + thread.read_with(cx, |thread, _| { + assert!( + thread.entries().iter().all(|entry| !matches!( + entry, + acp_thread::AgentThreadEntry::Elicitation(_) + )), + "request-scoped elicitation should not be inserted into a session thread" + ); + }); + } + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + + let response = response_rx + .recv() + .await + .expect("fake auth flow should receive elicitation response"); + assert_eq!(response.action, acp::ElicitationAction::Decline); + auth_task.await.expect("auth should complete"); + } + #[test] fn cursor_client_capabilities_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false); + let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID)); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -2559,7 +3057,7 @@ mod tests { #[test] fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -2568,8 +3066,8 @@ mod tests { } #[test] - fn client_capabilities_include_boolean_config_options_when_supported() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), true); + fn client_capabilities_include_boolean_config_options() { + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); assert!( capabilities @@ -2580,13 +3078,6 @@ mod tests { ); } - #[test] - fn client_capabilities_omit_boolean_config_options_when_unsupported() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); - - assert!(capabilities.session.is_none()); - } - #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { let command = AgentServerCommand { @@ -3072,69 +3563,7 @@ mod tests { } #[gpui::test] - async fn default_config_options_skip_boolean_defaults_when_acp_beta_is_disabled( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| init_settings_with_acp_beta_override(false, cx)); - - let (connection, set_config_requests) = connect_config_defaults_test_agent(cx).await; - connection.defaults.set( - None, - HashMap::from_iter([ - ( - "web_search".to_string(), - AgentConfigOptionValue::Boolean(true), - ), - ("mode".to_string(), AgentConfigOptionValue::from("manual")), - ]), - ); - let config_options = Rc::new(RefCell::new(vec![ - acp::SessionConfigOption::boolean("web_search", "Web Search", false), - acp::SessionConfigOption::select( - "mode", - "Mode", - "auto", - vec![ - acp::SessionConfigSelectOption::new("auto", "Auto"), - acp::SessionConfigSelectOption::new("manual", "Manual"), - ], - ), - ])); - - let mut async_cx = cx.to_async(); - connection.apply_default_config_options( - &acp::SessionId::new("session-config-defaults"), - &config_options, - &mut async_cx, - ); - drop(async_cx); - cx.run_until_parked(); - - let requests = set_config_requests - .lock() - .expect("set config requests mutex poisoned"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].config_id, acp::SessionConfigId::new("mode")); - assert_eq!( - requests[0].value, - acp::SessionConfigOptionValue::value_id("manual") - ); - - let options = config_options.borrow(); - assert!( - matches!(&options[0].kind, acp::SessionConfigKind::Boolean(boolean) if !boolean.current_value) - ); - assert!( - matches!(&options[1].kind, acp::SessionConfigKind::Select(select) if select.current_value == acp::SessionConfigValueId::new("manual")) - ); - } - - #[gpui::test] - async fn default_config_options_apply_boolean_defaults_when_acp_beta_is_enabled( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| init_settings_with_acp_beta_override(true, cx)); - + async fn default_config_options_apply_boolean_defaults(cx: &mut gpui::TestAppContext) { let (connection, set_config_requests) = connect_config_defaults_test_agent(cx).await; connection.defaults.set( None, @@ -3177,19 +3606,6 @@ mod tests { ); } - fn init_settings_with_acp_beta_override(enabled: bool, cx: &mut App) { - let mut store = settings::SettingsStore::test(cx); - store.register_setting::(); - store.update_user_settings(cx, |content| { - content.feature_flags.get_or_insert_default().insert( - AcpBetaFeatureFlag::NAME.to_string(), - if enabled { "on" } else { "off" }.to_string(), - ); - }); - cx.set_global(store); - cx.update_flags(false, Vec::new()); - } - async fn connect_config_defaults_test_agent( cx: &mut gpui::TestAppContext, ) -> ( @@ -3243,10 +3659,12 @@ mod tests { let sessions = Rc::new(RefCell::new(HashMap::default())); let connection = cx.update(|cx| { + let request_elicitations = cx.new(|_| ElicitationStore::default()); AcpConnection::new_for_test( client_conn, sessions, acp::AgentCapabilities::default(), + request_elicitations, WeakEntity::new_invalid(), client_io_task, Task::ready(()), @@ -3519,9 +3937,11 @@ mod tests { let agent_capabilities = response.agent_capabilities; + let request_elicitations = cx.new(|_| ElicitationStore::default()); let dispatch_context = ClientContext { sessions: sessions.clone(), session_list: client_session_list.clone(), + request_elicitations: request_elicitations.clone(), }; // `TestAppContext::spawn` hands out an `AsyncApp` by value, whereas the // production path uses `Context::spawn` which hands out `&mut AsyncApp`. @@ -3545,6 +3965,7 @@ mod tests { client_conn, sessions, agent_capabilities, + request_elicitations, agent_server_store, client_io_task, dispatch_task, @@ -3709,6 +4130,7 @@ mod tests { acp_thread::AgentThreadEntry::UserMessage(_) => "user", acp_thread::AgentThreadEntry::AssistantMessage(_) => "assistant", acp_thread::AgentThreadEntry::ToolCall(_) => "tool_call", + acp_thread::AgentThreadEntry::Elicitation(_) => "elicitation", acp_thread::AgentThreadEntry::CompletedPlan(_) => "plan", acp_thread::AgentThreadEntry::ContextCompaction(_) => "compaction", }) @@ -4164,6 +4586,124 @@ fn handle_request_permission( .detach(); } +fn handle_create_elicitation( + args: acp::CreateElicitationRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + match args.scope() { + acp::ElicitationScope::Session(scope) => { + let thread = match session_thread(ctx, &scope.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; + + let (elicitation_id, task) = match thread + .update(cx, |thread, cx| { + thread.request_elicitation_with_id(args, cx) + }) + .flatten_acp() + { + Ok(task) => task, + Err(e) => return respond_err(responder, e), + }; + + let cancellation = responder.cancellation(); + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = cancellation + .run_until_cancelled(async { Ok(task.await) }) + .await; + + match result { + Ok(response) => { + responder.respond(response).log_err(); + } + Err(e) => { + if e.code == ErrorCode::RequestCancelled { + thread + .update(cx, |thread, cx| { + thread.cancel_elicitation(&elicitation_id, cx) + }) + .log_err(); + } + respond_err(responder, e); + } + } + }) + .detach(); + } + acp::ElicitationScope::Request(_) => { + let store = ctx.request_elicitations.clone(); + let (elicitation_id, task) = + match store.update(cx, |store, cx| store.request_elicitation_with_id(args, cx)) { + Ok(task) => task, + Err(e) => return respond_err(responder, e), + }; + let store = store.downgrade(); + + let cancellation = responder.cancellation(); + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = cancellation + .run_until_cancelled(async { Ok(task.await) }) + .await; + + match result { + Ok(response) => { + responder.respond(response).log_err(); + } + Err(e) => { + if e.code == ErrorCode::RequestCancelled { + store + .update(cx, |store, cx| { + store.cancel_elicitation(&elicitation_id, cx) + }) + .log_err(); + } + respond_err(responder, e); + } + } + }) + .detach(); + } + _ => { + respond_err( + responder, + acp::Error::invalid_params().data("unknown elicitation scope"), + ); + } + } +} + +fn handle_complete_elicitation( + args: acp::CompleteElicitationNotification, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let threads = ctx + .sessions + .borrow() + .values() + .map(|session| session.thread.clone()) + .collect::>(); + let request_elicitations = ctx.request_elicitations.clone(); + let elicitation_id = args.elicitation_id; + + cx.spawn(async move |cx| { + for thread in threads { + thread + .update(cx, |thread, cx| { + thread.complete_url_elicitation(&elicitation_id, cx); + }) + .ok(); + } + request_elicitations.update(cx, |store, cx| { + store.complete_url_elicitation(&elicitation_id, cx); + }); + }) + .detach(); +} + fn handle_write_text_file( args: acp::WriteTextFileRequest, responder: Responder, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index c79ebdc45c0bab..9845a4f5995f93 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -227,23 +227,9 @@ impl AgentServer for CustomAgentServer { extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } if is_registry_agent { - match agent_id.as_ref() { - CLAUDE_AGENT_ID => { - extra_env.insert("ANTHROPIC_API_KEY".into(), "".into()); - } - CODEX_ID => { - if let Ok(api_key) = std::env::var("CODEX_API_KEY") { - extra_env.insert("CODEX_API_KEY".into(), api_key); - } - if let Ok(api_key) = std::env::var("OPEN_AI_API_KEY") { - extra_env.insert("OPEN_AI_API_KEY".into(), api_key); - } - } - GEMINI_ID => { - extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); - } - _ => {} - } + extra_env.extend(registry_agent_env_overrides(agent_id.as_ref(), &|key| { + std::env::var(key).ok() + })); } let store = delegate.store.downgrade(); cx.spawn(async move |cx| { @@ -302,6 +288,25 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task> { }) } +/// Environment overrides Zed injects when launching specific registry-managed +/// agents. Kept as a pure function (with the process environment abstracted +/// behind `process_env`) so the per-agent behavior can be unit tested without +/// spawning a server. +fn registry_agent_env_overrides( + agent_id: &str, + process_env: &dyn Fn(&str) -> Option, +) -> Vec<(String, String)> { + match agent_id { + CLAUDE_AGENT_ID => vec![("ANTHROPIC_API_KEY".to_owned(), String::new())], + CODEX_ID => ["CODEX_API_KEY", "OPEN_AI_API_KEY"] + .into_iter() + .filter_map(|key| Some((key.to_owned(), process_env(key)?))) + .collect(), + GEMINI_ID => vec![("SURFACE".to_owned(), "zed".to_owned())], + _ => Vec::new(), + } +} + fn is_registry_agent(agent_id: impl Into, cx: &App) -> bool { let agent_id = agent_id.into(); let is_in_registry = project::AgentRegistryStore::try_global(cx) @@ -391,6 +396,115 @@ mod tests { }); } + #[test] + fn test_registry_agent_env_overrides_claude() { + let overrides = registry_agent_env_overrides(CLAUDE_AGENT_ID, &|_| None); + assert_eq!( + overrides, + vec![("ANTHROPIC_API_KEY".to_owned(), String::new())], + "claude-acp should get a blanked ANTHROPIC_API_KEY so login state is used" + ); + } + + #[test] + fn test_registry_agent_env_overrides_codex() { + let process_env = |key: &str| match key { + "CODEX_API_KEY" => Some("codex-key".to_owned()), + "OPEN_AI_API_KEY" => Some("openai-key".to_owned()), + _ => None, + }; + let overrides = registry_agent_env_overrides(CODEX_ID, &process_env); + assert_eq!( + overrides, + vec![ + ("CODEX_API_KEY".to_owned(), "codex-key".to_owned()), + ("OPEN_AI_API_KEY".to_owned(), "openai-key".to_owned()), + ], + "codex-acp should forward API keys from the process environment" + ); + + let overrides = registry_agent_env_overrides(CODEX_ID, &|_| None); + assert_eq!( + overrides, + Vec::new(), + "codex-acp should not inject keys that are absent from the process environment" + ); + } + + #[test] + fn test_registry_agent_env_overrides_gemini_and_unknown() { + assert_eq!( + registry_agent_env_overrides(GEMINI_ID, &|_| None), + vec![("SURFACE".to_owned(), "zed".to_owned())] + ); + assert_eq!( + registry_agent_env_overrides("some-other-agent", &|_| Some("value".to_owned())), + Vec::new(), + "unknown agents should get no environment overrides" + ); + } + + #[gpui::test] + async fn test_custom_agent_settings_round_trip_to_resolved_command(cx: &mut TestAppContext) { + init_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/root", serde_json::json!({})).await; + let project = project::Project::test(fs, [std::path::Path::new("/root")], cx).await; + + set_agent_server_settings( + cx, + vec![( + "my-made-up-agent", + settings::CustomAgentServerSettings::Custom { + path: "/bin/my-agent".into(), + args: vec!["--acp".to_owned()], + env: HashMap::from_iter([("FOO".to_owned(), "bar".to_owned())]), + default_mode: None, + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + }, + )], + ); + cx.run_until_parked(); + + let store = project.read_with(cx, |project, _| project.agent_server_store().clone()); + let command = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&AgentId::new("my-made-up-agent")) + .expect("custom agent from settings should be registered in the store"); + agent.get_command( + vec!["--extra-arg".to_owned()], + HashMap::from_iter([("EXTRA".to_owned(), "1".to_owned())]), + &mut cx.to_async(), + ) + }) + .await + .expect("resolving the custom agent command should succeed"); + + assert_eq!(command.path, std::path::PathBuf::from("/bin/my-agent")); + assert_eq!( + command.args, + vec!["--acp".to_owned(), "--extra-arg".to_owned()] + ); + let env: HashMap = command + .env + .expect("resolved command should carry an environment") + .into_iter() + .collect(); + assert_eq!( + env.get("FOO").map(String::as_str), + Some("bar"), + "settings env should survive the round trip" + ); + assert_eq!( + env.get("EXTRA").map(String::as_str), + Some("1"), + "connect-time extra env should be merged into the resolved command" + ); + } + #[gpui::test] fn test_unknown_agent_is_not_registry(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_settings/src/agent_profile.rs b/crates/agent_settings/src/agent_profile.rs index ca448ca85710aa..1283dbf1ea1508 100644 --- a/crates/agent_settings/src/agent_profile.rs +++ b/crates/agent_settings/src/agent_profile.rs @@ -7,7 +7,7 @@ use fs::Fs; use gpui::{App, SharedString}; use settings::{ AgentProfileContent, ContextServerPresetContent, LanguageModelSelection, Settings as _, - SettingsContent, update_settings_file, + SettingsContent, SettingsStore, update_settings_file, }; use util::ResultExt as _; @@ -116,6 +116,32 @@ impl AgentProfileSettings { self.tools.get(tool_name) == Some(&true) } + /// Whether the built-in profile with the given id still matches the shipped + /// default — i.e. the user has neither customized the built-in profile nor + /// shadowed it with a custom profile of the same id. Custom profile ids are + /// never considered unmodified defaults. + pub fn is_unmodified_default(profile_id: &AgentProfileId, cx: &App) -> bool { + if !builtin_profiles::is_builtin(profile_id) { + return false; + } + let store = cx.global::(); + let profile_in = |content: &SettingsContent| { + content + .agent + .as_ref() + .and_then(|agent| agent.profiles.as_ref()) + .and_then(|profiles| profiles.get(profile_id.as_str())) + .cloned() + }; + match ( + profile_in(store.merged_settings()), + profile_in(store.raw_default_settings()), + ) { + (Some(merged), Some(default)) => merged == default, + _ => false, + } + } + pub fn is_context_server_tool_enabled(&self, server_id: &str, tool_name: &str) -> bool { self.context_servers .get(server_id) @@ -247,4 +273,37 @@ mod tests { assert!(!profile.is_context_server_tool_enabled("server", "other_tool")); assert!(!profile.is_context_server_tool_enabled("other_server", "any_tool")); } + + #[gpui::test] + fn unmodified_default_detection(cx: &mut gpui::App) { + use gpui::UpdateGlobal as _; + + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + let write = AgentProfileId(builtin_profiles::WRITE.into()); + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + let custom = AgentProfileId("custom".into()); + + // Fresh defaults: the shipped built-in profiles are unmodified. + assert!(AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + // Custom (non-built-in) ids are never considered unmodified defaults. + assert!(!AgentProfileSettings::is_unmodified_default(&custom, cx)); + + // The user customizes the `write` profile; `minimal` stays untouched. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ "agent": { "profiles": { "write": { "name": "Write", "tools": { "fetch": false } } } } }"#, + cx, + ) + .unwrap(); + }); + + assert!(!AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 8b6607c9df2e42..af9ea81a357ff2 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -406,7 +406,7 @@ impl Default for AgentProfileId { /// combines them with the in-memory per-thread grants. `write_paths` are /// stored as minimal, lexically-normalized subtrees (see /// [`compile_sandbox_permissions`]). -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct SandboxPermissions { /// Allow sandboxed commands to reach any host over the network. pub allow_all_hosts: bool, @@ -414,8 +414,6 @@ pub struct SandboxPermissions { /// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where /// consumed (`agent::sandboxing`). pub network_hosts: Vec, - /// Allow sandboxed commands to access protected Git metadata paths. - pub allow_git_access: bool, pub allow_fs_write_all: bool, /// Persistently run agent terminal commands outside the OS sandbox. This is /// the model-facing "off switch": when set, the sandboxed terminal tool is @@ -426,6 +424,24 @@ pub struct SandboxPermissions { /// tool/prompt in place — see `agent::sandboxing`. pub allow_unsandboxed: bool, pub write_paths: Vec, + /// Whether sandbox escalation prompts warn about domains or write paths + /// that contain potentially confusable Unicode characters (homoglyphs, + /// invisible characters, or bidirectional overrides). Enabled by default. + pub warn_confusable_unicode: bool, +} + +impl Default for SandboxPermissions { + fn default() -> Self { + Self { + allow_all_hosts: false, + network_hosts: Vec::new(), + allow_fs_write_all: false, + allow_unsandboxed: false, + write_paths: Vec::new(), + // The confusable-Unicode warning is a safety net, so it defaults on. + warn_confusable_unicode: true, + } + } } #[derive(Clone, Debug, Default)] @@ -811,10 +827,10 @@ fn compile_sandbox_permissions( SandboxPermissions { allow_all_hosts: content.allow_all_hosts.unwrap_or(false), network_hosts, - allow_git_access: content.allow_git_access.unwrap_or(false), allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), write_paths, + warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true), } } @@ -1092,6 +1108,22 @@ mod tests { fn test_sandbox_permissions_empty() { let permissions = compile_sandbox_permissions(None); assert_eq!(permissions, SandboxPermissions::default()); + // The confusable-Unicode warning is a safety net, so it's on by default. + assert!(permissions.warn_confusable_unicode); + } + + #[test] + fn test_sandbox_permissions_warn_confusable_unicode_can_be_disabled() { + let content: settings::SandboxPermissionsContent = + serde_json::from_value(json!({ "warn_confusable_unicode": false })).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + assert!(!permissions.warn_confusable_unicode); + + // Omitting the key keeps the warning enabled. + let content: settings::SandboxPermissionsContent = + serde_json::from_value(json!({})).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + assert!(permissions.warn_confusable_unicode); } #[test] @@ -1099,7 +1131,6 @@ mod tests { let json = json!({ "allow_all_hosts": true, "network_hosts": ["github.com", "*.npmjs.org"], - "allow_git_access": true, "allow_unsandboxed": true, "write_paths": [ "/tmp/build/cache", @@ -1116,7 +1147,6 @@ mod tests { permissions.network_hosts, vec!["github.com".to_string(), "*.npmjs.org".to_string()] ); - assert!(permissions.allow_git_access); assert!(!permissions.allow_fs_write_all); assert!(permissions.allow_unsandboxed); assert_eq!( diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 8b25804d724350..7dee5f497fb099 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -30,6 +30,7 @@ acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true agent.workspace = true +agent_detect.workspace = true async-channel.workspace = true agent_servers.workspace = true agent_settings.workspace = true @@ -63,6 +64,7 @@ gpui.workspace = true gpui_tokio.workspace = true html_to_markdown.workspace = true http_client.workspace = true +idna.workspace = true indoc.workspace = true itertools.workspace = true jsonschema.workspace = true @@ -107,7 +109,7 @@ theme.workspace = true theme_settings.workspace = true time.workspace = true ui.workspace = true -ui_input.workspace = true +unicode-script.workspace = true unicode-segmentation.workspace = true url.workspace = true util.workspace = true @@ -143,6 +145,7 @@ remote_server = { workspace = true, features = ["test-support"] } search = { workspace = true, features = ["test-support"] } semver.workspace = true +shlex.workspace = true reqwest_client.workspace = true tempfile.workspace = true vim.workspace = true diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 6164fcf72bc4d7..ec700744438c0f 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,1685 +1,6 @@ -mod add_llm_provider_modal; pub mod configure_context_server_modal; -mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; -use std::{ops::Range, rc::Rc, sync::Arc}; - -use agent::ContextServerRegistry; -use anyhow::Result; -use cloud_api_types::Plan; -use collections::HashMap; -use context_server::ContextServerId; -use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll}; -use extension::ExtensionManifest; -use extension_host::ExtensionStore; -use fs::Fs; -use gpui::{ - Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, - ScrollHandle, Subscription, Task, TaskExt, WeakEntity, -}; -use itertools::Itertools; -use language::LanguageRegistry; -use language_model::{ - IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, - ZED_CLOUD_PROVIDER_ID, -}; -use language_models::AllLanguageModelSettings; -use notifications::status_toast::StatusToast; -use project::{ - agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, - context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, -}; -use settings::{Settings, SettingsContent, SettingsStore, update_settings_file}; -use ui::{ - 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}; -use zed_actions::{ExtensionCategoryFilter, OpenBrowser}; - 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, - 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, - _subscriptions: Vec, - scroll_handle: ScrollHandle, -} - -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, - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - - 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(&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, - language_registry, - workspace, - 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, - _subscriptions: subscriptions, - scroll_handle: ScrollHandle::new(), - }; - - this.build_provider_configuration_views(window, cx); - this - } - - fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context) { - let providers = LanguageModelRegistry::read_global(cx).visible_providers(); - for provider in providers { - self.add_provider_configuration_view(&provider, window, cx); - } - } - - fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) { - self.configuration_views_by_provider.remove(provider_id); - self.expanded_provider_configurations.remove(provider_id); - } - - fn add_provider_configuration_view( - &mut self, - provider: &Arc, - window: &mut Window, - cx: &mut Context, - ) { - let configuration_view = provider.configuration_view( - language_model::ConfigurationViewTargetAgent::ZedAgent, - window, - cx, - ); - self.configuration_views_by_provider - .insert(provider.id(), configuration_view); - } -} - -impl Focusable for AgentConfiguration { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -pub enum AssistantConfigurationEvent { - NewThread(Arc), -} - -impl EventEmitter for AgentConfiguration {} - -enum AgentIcon { - Name(IconName), - Path(SharedString), -} - -impl AgentConfiguration { - fn render_section_title( - &mut self, - title: impl Into, - description: impl Into, - menu: AnyElement, - ) -> impl IntoElement { - h_flex() - .p_4() - .pb_0() - .mb_2p5() - .items_start() - .justify_between() - .child( - v_flex() - .w_full() - .gap_0p5() - .child( - h_flex() - .pr_1() - .w_full() - .gap_2() - .justify_between() - .flex_wrap() - .child(Headline::new(title.into())) - .child(menu), - ) - .child(Label::new(description.into()).color(Color::Muted)), - ) - } - - fn render_provider_configuration_block( - &mut self, - provider: &Arc, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let provider_id = provider.id().0; - let provider_name = provider.name().0; - let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}")); - - let configuration_view = self - .configuration_views_by_provider - .get(&provider.id()) - .cloned(); - - let is_expanded = self - .expanded_provider_configurations - .get(&provider.id()) - .copied() - .unwrap_or(false); - - let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID; - let current_plan = if is_zed_provider { - self.workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan()) - } else { - None - }; - - let is_signed_in = self - .workspace - .read_with(cx, |workspace, _| { - !workspace.client().status().borrow().is_signed_out() - }) - .unwrap_or(false); - - v_flex() - .min_w_0() - .w_full() - .when(is_expanded, |this| this.mb_2()) - .child( - div() - .px_2() - .child(Divider::horizontal().color(DividerColor::BorderFaded)), - ) - .child( - h_flex() - .map(|this| { - if is_expanded { - this.mt_2().mb_1() - } else { - this.my_2() - } - }) - .w_full() - .justify_between() - .child( - h_flex() - .id(provider_id_string.clone()) - .px_2() - .py_0p5() - .w_full() - .justify_between() - .rounded_sm() - .hover(|hover| hover.bg(cx.theme().colors().element_hover)) - .child( - h_flex() - .w_full() - .gap_1p5() - .child( - match provider.icon() { - IconOrSvg::Svg(path) => Icon::from_external_svg(path), - IconOrSvg::Icon(name) => Icon::new(name), - } - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - h_flex() - .w_full() - .gap_1() - .child(Label::new(provider_name.clone())) - .map(|this| { - if is_zed_provider && is_signed_in { - this.child( - self.render_zed_plan_info(current_plan, cx), - ) - } else { - this.when( - provider.is_authenticated(cx) - && !is_expanded, - |parent| { - parent.child( - Icon::new(IconName::Check) - .color(Color::Success), - ) - }, - ) - } - }), - ), - ) - .child( - Disclosure::new(provider_id_string, is_expanded) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let provider_id = provider.id(); - move |this, _event, _window, _cx| { - let is_expanded = this - .expanded_provider_configurations - .entry(provider_id.clone()) - .or_insert(false); - - *is_expanded = !*is_expanded; - } - })), - ), - ) - .child( - v_flex() - .min_w_0() - .w_full() - .px_2() - .gap_1() - .when(is_expanded, |parent| match configuration_view { - Some(configuration_view) => parent.child(configuration_view), - None => parent.child(Label::new(format!( - "No configuration view for {provider_name}", - ))), - }) - .when(is_expanded && provider.is_authenticated(cx), |parent| { - parent.child( - Button::new( - SharedString::from(format!("new-thread-{provider_id}")), - "Start New Thread", - ) - .full_width() - .style(ButtonStyle::Outlined) - .layer(ElevationIndex::ModalSurface) - .start_icon( - Icon::new(IconName::Thread) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let provider = provider.clone(); - move |_this, _event, _window, cx| { - cx.emit(AssistantConfigurationEvent::NewThread( - provider.clone(), - )) - } - })), - ) - }) - .when( - is_expanded && is_removable_provider(&provider.id(), cx), - |this| { - this.child( - Button::new( - SharedString::from(format!("delete-provider-{provider_id}")), - "Remove Provider", - ) - .full_width() - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Trash) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let provider = provider.clone(); - move |this, _event, window, cx| { - this.delete_provider(provider.clone(), window, cx); - } - })), - ) - }, - ), - ) - } - - fn delete_provider( - &mut self, - provider: Arc, - window: &mut Window, - cx: &mut Context, - ) { - let fs = self.fs.clone(); - let provider_id = provider.id(); - - cx.spawn_in(window, async move |_, cx| { - cx.update(|_window, cx| { - update_settings_file(fs.clone(), cx, { - let provider_id = provider_id.clone(); - move |settings, _| { - remove_compatible_provider(settings, provider_id.0.as_ref()); - } - }); - }) - .log_err(); - - cx.update(|_window, cx| { - LanguageModelRegistry::global(cx).update(cx, { - let provider_id = provider_id.clone(); - move |registry, cx| { - registry.unregister_provider(provider_id, cx); - } - }) - }) - .log_err(); - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - fn render_provider_configuration_section( - &mut self, - cx: &mut Context, - ) -> impl IntoElement { - let providers = LanguageModelRegistry::read_global(cx).visible_providers(); - - let popover_menu = PopoverMenu::new("add-provider-popover") - .trigger( - Button::new("add-provider", "Add Provider") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - let workspace = self.workspace.clone(); - move |window, cx| { - let open_modal = |provider: LlmCompatibleProvider| { - let workspace = workspace.clone(); - move |window: &mut Window, cx: &mut App| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle(provider, workspace, window, cx); - }) - .log_err(); - } - }; - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.header("Compatible APIs") - .entry("OpenAI", None, open_modal(LlmCompatibleProvider::OpenAi)) - .entry( - "Anthropic", - None, - open_modal(LlmCompatibleProvider::Anthropic), - ) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .w_full() - .child(self.render_section_title( - "LLM Providers", - "Add at least one provider to use AI-powered features with Zed's native agent.", - popover_menu.into_any_element(), - )) - .child( - div() - .w_full() - .pl(DynamicSpacing::Base08.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .children( - providers.into_iter().map(|provider| { - self.render_provider_configuration_block(&provider, cx) - }), - ), - ) - } - - fn render_zed_plan_info(&self, plan: Option, cx: &mut Context) -> impl IntoElement { - if let Some(plan) = plan { - let free_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.05)); - - let pro_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.2)); - - let (plan_name, label_color, bg_color) = match plan { - Plan::ZedFree => ("Free", Color::Default, free_chip_bg), - Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), - Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), - Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), - Plan::ZedVip => ("VIP", Color::Accent, pro_chip_bg), - Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), - }; - - Chip::new(plan_name.to_string()) - .bg_color(bg_color) - .label_color(label_color) - .into_any_element() - } else { - div().into_any_element() - } - } - - fn render_context_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { - let context_server_ids = self.context_server_store.read(cx).server_ids(); - - let add_server_popover = PopoverMenu::new("add-server-popover") - .trigger( - Button::new("add-server", "Add Server") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Add Custom Server", None, { - |window, cx| { - window.dispatch_action( - crate::AddContextServer::local().boxed_clone(), - cx, - ) - } - }) - .entry("Add Remote Server", None, { - |window, cx| { - window.dispatch_action( - crate::AddContextServer::remote().boxed_clone(), - cx, - ) - } - }) - .entry("Install from Extensions", None, { - |window, cx| { - window.dispatch_action( - zed_actions::Extensions { - category_filter: Some( - ExtensionCategoryFilter::ContextServers, - ), - id: None, - } - .boxed_clone(), - cx, - ) - } - }) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(self.render_section_title( - "Model Context Protocol (MCP) Servers", - "All MCP servers connected directly or via a Zed extension.", - add_server_popover.into_any_element(), - )) - .child( - v_flex() - .pl_4() - .pb_4() - .pr_5() - .w_full() - .gap_1() - .map(|parent| { - if context_server_ids.is_empty() { - parent.child( - h_flex() - .p_4() - .justify_center() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.6)) - .rounded_sm() - .child( - Label::new("No MCP servers added yet.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - } else { - parent.children(itertools::intersperse_with( - context_server_ids.iter().cloned().map(|context_server_id| { - self.render_context_server(context_server_id, cx) - .into_any_element() - }), - || { - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element() - }, - )) - } - }), - ) - } - - fn render_context_server( - &self, - context_server_id: ContextServerId, - cx: &Context, - ) -> impl use<> + IntoElement { - let server_status = self - .context_server_store - .read(cx) - .status_for_server(&context_server_id) - .unwrap_or(ContextServerStatus::Stopped); - let server_configuration = self - .context_server_store - .read(cx) - .configuration_for_server(&context_server_id); - - let is_running = matches!(server_status, ContextServerStatus::Running); - let item_id = SharedString::from(context_server_id.0.clone()); - // Servers without a configuration can only be provided by extensions. - let provided_by_extension = server_configuration.as_ref().is_none_or(|config| { - matches!( - config.as_ref(), - ContextServerConfiguration::Extension { .. } - ) - }); - - 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 { - None - }; - let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); - let client_secret_required = matches!( - server_status, - ContextServerStatus::ClientSecretRequired { .. } - ); - let authenticating = matches!(server_status, ContextServerStatus::Authenticating); - let context_server_store = self.context_server_store.clone(); - let workspace = self.workspace.clone(); - let language_registry = self.language_registry.clone(); - - let tool_count = self - .context_server_registry - .read(cx) - .tools_for_server(&context_server_id) - .count(); - - let source = if provided_by_extension { - AiSettingItemSource::Extension - } else { - AiSettingItemSource::Custom - }; - - 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::ClientSecretRequired { .. } => { - AiSettingItemStatus::ClientSecretRequired - } - ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, - }; - - let is_remote = server_configuration - .as_ref() - .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. })) - .unwrap_or(false); - - let should_show_logout_button = server_configuration.as_ref().is_some_and(|config| { - matches!(config.as_ref(), ContextServerConfiguration::Http { .. }) - && !config.has_static_auth_header() - }); - - let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") - .trigger_with_tooltip( - IconButton::new("context-server-config-menu", IconName::Settings) - .icon_color(Color::Muted) - .icon_size(IconSize::Small), - Tooltip::text("Configure MCP Server"), - ) - .anchor(Anchor::TopRight) - .menu({ - let fs = self.fs.clone(); - let context_server_id = context_server_id.clone(); - let language_registry = self.language_registry.clone(); - let workspace = self.workspace.clone(); - let context_server_registry = self.context_server_registry.clone(); - let context_server_store = context_server_store.clone(); - - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Configure Server", None, { - let context_server_id = context_server_id.clone(); - let language_registry = language_registry.clone(); - let workspace = workspace.clone(); - move |window, cx| { - if is_remote { - crate::agent_configuration::configure_context_server_modal::ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } else { - ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } - } - }).when(tool_count > 0, |this| this.entry("View Tools", None, { - let context_server_id = context_server_id.clone(); - let context_server_registry = context_server_registry.clone(); - let workspace = workspace.clone(); - move |window, cx| { - let context_server_id = context_server_id.clone(); - workspace.update(cx, |workspace, cx| { - ConfigureContextServerToolsModal::toggle( - context_server_id, - context_server_registry.clone(), - workspace, - window, - cx, - ); - }) - .ok(); - } - })) - .when(should_show_logout_button, |this| { - this.entry("Log Out", None, { - let context_server_store = context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |_window, cx| { - context_server_store.update(cx, |store, cx| { - store.logout_server(&context_server_id, cx).log_err(); - }); - } - }) - }) - .separator() - .entry("Uninstall", None, { - let fs = fs.clone(); - let context_server_id = context_server_id.clone(); - let workspace = workspace.clone(); - move |_, cx| { - let uninstall_extension_task = match ( - provided_by_extension, - resolve_extension_for_context_server(&context_server_id, cx), - ) { - (true, Some((id, manifest))) => { - if extension_only_provides_context_server(manifest.as_ref()) - { - ExtensionStore::global(cx).update(cx, |store, cx| { - store.uninstall_extension(id, cx) - }) - } else { - workspace.update(cx, |workspace, cx| { - show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx); - }).log_err(); - Task::ready(Ok(())) - } - } - _ => Task::ready(Ok(())), - }; - - cx.spawn({ - let fs = fs.clone(); - let context_server_id = context_server_id.clone(); - async move |cx| { - uninstall_extension_task.await?; - cx.update(|cx| { - update_settings_file( - fs.clone(), - cx, - { - let context_server_id = - context_server_id.clone(); - move |settings, _| { - settings.project - .context_servers - .remove(&context_server_id.0); - } - }, - ) - }); - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - } - }) - })) - } - }); - - let feedback_base_container = - || h_flex().py_1().min_w_0().w_full().gap_1().justify_between(); - - let details: Option = if let Some(error) = error { - Some( - 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(); - }); - } - }), - ) - }) - .into_any_element(), - ) - } else if auth_required { - Some( - 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("authenticate-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 client_secret_required { - Some( - 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("Enter a client secret to connect this server") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - .child( - Button::new("enter-client-secret", "Enter Client Secret") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_id = context_server_id.clone(); - move |_event, window, cx| { - ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } - }), - ) - .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 - }; - - 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 agents = agent_server_store - .external_agents() - .cloned() - .collect::>(); - - let agents: Vec<_> = agents - .into_iter() - .map(|name| { - let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { - AgentIcon::Path(icon_path) - } else { - AgentIcon::Name(IconName::Sparkle) - }; - let display_name = agent_server_store - .agent_display_name(&name) - .unwrap_or_else(|| name.0.clone()); - let source = agent_server_store.agent_source(&name).unwrap_or_default(); - (name, icon, display_name, source) - }) - .sorted_unstable_by_key(|(_, _, display_name, _)| display_name.to_lowercase()) - .collect(); - - let add_agent_popover = PopoverMenu::new("add-agent-server-popover") - .trigger( - Button::new("add-agent", "Add Agent") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Install from Registry", None, { - |window, cx| { - window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx) - } - }) - .entry("Add Custom Agent", None, { - move |window, cx| { - if let Some(workspace) = Workspace::for_window(window, cx) { - let workspace = workspace.downgrade(); - window - .spawn(cx, async |cx| { - open_new_agent_servers_entry_in_settings_editor( - workspace, cx, - ) - .await - }) - .detach_and_log_err(cx); - } - } - }) - .separator() - .header("Learn More") - .item( - ContextMenuEntry::new("ACP Docs") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler({ - move |window, cx| { - window.dispatch_action( - Box::new(OpenBrowser { - url: "https://agentclientprotocol.com/".into(), - }), - cx, - ); - } - }), - ) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - v_flex() - .child(self.render_section_title( - "External Agents", - "All agents connected through the Agent Client Protocol.", - add_agent_popover.into_any_element(), - )) - .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() - }, - )), - ), - ) - } - - fn render_agent_server( - &self, - icon: AgentIcon, - id: impl Into, - display_name: impl Into, - source: ExternalAgentSource, - cx: &mut Context, - ) -> impl IntoElement { - let id = id.into(); - let display_name = display_name.into(); - - let icon = match icon { - AgentIcon::Name(icon_name) => Icon::new(icon_name) - .size(IconSize::Small) - .color(Color::Muted), - AgentIcon::Path(icon_path) => Icon::from_external_svg(icon_path) - .size(IconSize::Small) - .color(Color::Muted), - }; - - let source_kind = match source { - 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, running_version) = { - let connection_store = self.agent_connection_store.read(cx); - ( - connection_store.connection_status(&agent, cx), - connection_store.agent_version(&agent, cx), - ) - }; - - let restart_button = matches!( - connection_status, - 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::Registry => { - let fs = self.fs.clone(); - Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove Registry Agent")) - .on_click(cx.listener(move |_, _, _window, cx| { - let agent_name = agent_server_name.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let Some(agent_servers) = settings.agent_servers.as_mut() else { - return; - }; - if let Some(entry) = agent_servers.get(agent_name.0.as_ref()) - && matches!( - entry, - settings::CustomAgentServerSettings::Registry { .. } - ) - { - agent_servers.remove(agent_name.0.as_ref()); - } - }); - })), - ) - } - ExternalAgentSource::Custom => { - let fs = self.fs.clone(); - Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove Custom Agent")) - .on_click(cx.listener(move |_, _, _window, cx| { - let agent_name = agent_server_name.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let Some(agent_servers) = settings.agent_servers.as_mut() else { - return; - }; - if let Some(entry) = agent_servers.get(agent_name.0.as_ref()) - && matches!( - entry, - settings::CustomAgentServerSettings::Custom { .. } - ) - { - agent_servers.remove(agent_name.0.as_ref()); - } - }); - })), - ) - } - }; - - 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(running_version, |this, version| this.detail_label(version)) - .when_some(restart_button, |this, button| this.action(button)) - .when_some(uninstall_button, |this, button| this.action(button)) - } -} - -impl Render for AgentConfiguration { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("assistant-configuration") - .key_context("AgentConfiguration") - .track_focus(&self.focus_handle(cx)) - .relative() - .size_full() - .pb_8() - .bg(cx.theme().colors().panel_background) - .child( - div() - .size_full() - .child( - v_flex() - .id("assistant-configuration-content") - .track_scroll(&self.scroll_handle) - .size_full() - .min_w_0() - .overflow_y_scroll() - .child(self.render_agent_servers_section(cx)) - .child(self.render_context_servers_section(cx)) - .child(self.render_provider_configuration_section(cx)), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx), - ) - } -} - -fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool { - manifest.context_servers.len() == 1 - && manifest.themes.is_empty() - && manifest.icon_themes.is_empty() - && manifest.languages.is_empty() - && manifest.grammars.is_empty() - && manifest.language_servers.is_empty() - && manifest.slash_commands.is_empty() - && manifest.snippets.is_none() - && manifest.debug_locators.is_empty() -} - -pub(crate) fn resolve_extension_for_context_server( - id: &ContextServerId, - cx: &App, -) -> Option<(Arc, Arc)> { - ExtensionStore::global(cx) - .read(cx) - .installed_extensions() - .iter() - .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) - .map(|(id, entry)| (id.clone(), entry.manifest.clone())) -} - -// This notification appears when trying to delete -// an MCP server extension that not only provides -// the server, but other things, too, like language servers and more. -fn show_unable_to_uninstall_extension_with_context_server( - workspace: &mut Workspace, - id: ContextServerId, - cx: &mut App, -) { - let workspace_handle = workspace.weak_handle(); - let context_server_id = id.clone(); - - let status_toast = StatusToast::new( - format!( - "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?", - id.0 - ), - cx, - move |this, _cx| { - let workspace_handle = workspace_handle.clone(); - - this.icon( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .dismiss_button(true) - .action("Uninstall", move |_, _cx| { - if let Some((extension_id, _)) = - resolve_extension_for_context_server(&context_server_id, _cx) - { - ExtensionStore::global(_cx).update(_cx, |store, cx| { - store - .uninstall_extension(extension_id, cx) - .detach_and_log_err(cx); - }); - - workspace_handle - .update(_cx, |workspace, cx| { - let fs = workspace.app_state().fs.clone(); - cx.spawn({ - let context_server_id = context_server_id.clone(); - async move |_workspace_handle, cx| { - cx.update(|cx| { - update_settings_file(fs, cx, move |settings, _| { - settings - .project - .context_servers - .remove(&context_server_id.0); - }); - }); - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - }) - .log_err(); - } - }) - }, - ); - - workspace.toggle_status_toast(status_toast, cx); -} - -async fn open_new_agent_servers_entry_in_settings_editor( - workspace: WeakEntity, - cx: &mut AsyncWindowContext, -) -> Result<()> { - let settings_editor = workspace - .update_in(cx, |_, window, cx| { - create_and_open_local_file(paths::settings_file(), window, cx, || { - settings::initial_user_settings_content().as_ref().into() - }) - })? - .await? - .downcast::() - .unwrap(); - - settings_editor - .downgrade() - .update_in(cx, |item, window, cx| { - let text = item.buffer().read(cx).snapshot(cx).text(); - - let settings = cx.global::(); - - let mut unique_server_name = None; - 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_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }, - ); - } - }) - .log_err() - else { - return; - }; - - if edits.is_empty() { - return; - } - - let ranges = edits - .iter() - .map(|(range, _)| range.clone()) - .collect::>(); - - item.edit( - edits.into_iter().map(|(range, s)| { - ( - MultiBufferOffset(range.start)..MultiBufferOffset(range.end), - s, - ) - }), - cx, - ); - if let Some((unique_server_name, buffer)) = - unique_server_name.zip(item.buffer().read(cx).as_singleton()) - { - let snapshot = buffer.read(cx).snapshot(); - if let Some(range) = - find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot) - { - item.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_ranges(vec![ - MultiBufferOffset(range.start)..MultiBufferOffset(range.end), - ]); - }, - ); - } - } - }) -} - -fn find_text_in_buffer( - text: &str, - start: usize, - snapshot: &language::BufferSnapshot, -) -> Option> { - let chars = text.chars().collect::>(); - - let mut offset = start; - let mut char_offset = 0; - for c in snapshot.chars_at(start) { - if char_offset >= chars.len() { - break; - } - offset += 1; - - if c == chars[char_offset] { - char_offset += 1; - } else { - char_offset = 0; - } - } - - if char_offset == chars.len() { - Some(offset.saturating_sub(chars.len())..offset) - } else { - None - } -} - -// API-compatible providers are user-configured and can be removed, -// whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't. -// -// If in the future we have more "API-compatible-type" of providers, -// they should be included here as removable providers. -fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool { - let settings = AllLanguageModelSettings::get_global(cx); - settings - .openai_compatible - .contains_key(provider_id.0.as_ref()) - || settings - .anthropic_compatible - .contains_key(provider_id.0.as_ref()) -} - -fn remove_compatible_provider(settings: &mut SettingsContent, provider_id: &str) { - // Mirrors the OpenAI-wins precedence used at registration time: only the - // entry that is actually registered gets removed. A shadowed - // `anthropic_compatible` entry with the same name takes over instead of - // being silently deleted. - let Some(language_models) = settings.language_models.as_mut() else { - return; - }; - let removed_from_openai = language_models - .openai_compatible - .as_mut() - .and_then(|providers| providers.remove(provider_id)) - .is_some(); - if !removed_from_openai && let Some(providers) = language_models.anthropic_compatible.as_mut() { - providers.remove(provider_id); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use settings::{AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent}; - - fn settings_with_compatible_providers(openai: &[&str], anthropic: &[&str]) -> SettingsContent { - let mut settings = SettingsContent::default(); - let language_models = settings.language_models.get_or_insert_default(); - language_models.openai_compatible = Some( - openai - .iter() - .map(|id| { - ( - Arc::from(*id), - OpenAiCompatibleSettingsContent { - api_url: "https://example.com".to_string(), - available_models: Vec::new(), - custom_headers: None, - }, - ) - }) - .collect(), - ); - language_models.anthropic_compatible = Some( - anthropic - .iter() - .map(|id| { - ( - Arc::from(*id), - AnthropicCompatibleSettingsContent { - api_url: "https://example.com".to_string(), - available_models: Vec::new(), - custom_headers: None, - }, - ) - }) - .collect(), - ); - settings - } - - fn compatible_provider_keys(settings: &SettingsContent) -> (Vec<&str>, Vec<&str>) { - fn keys(providers: Option<&HashMap, T>>) -> Vec<&str> { - providers - .map(|providers| providers.keys().map(|key| key.as_ref()).collect()) - .unwrap_or_default() - } - - let language_models = settings - .language_models - .as_ref() - .expect("language_models settings should exist"); - ( - keys(language_models.openai_compatible.as_ref()), - keys(language_models.anthropic_compatible.as_ref()), - ) - } - - #[test] - fn test_remove_compatible_provider_openai_only() { - let mut settings = settings_with_compatible_providers(&["acme"], &[]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, Vec::<&str>::new()); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_anthropic_only() { - let mut settings = settings_with_compatible_providers(&[], &["acme"]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, Vec::<&str>::new()); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_collision_removes_only_openai_entry() { - let mut settings = settings_with_compatible_providers(&["acme"], &["acme"]); - - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!( - openai, - Vec::<&str>::new(), - "the registered (OpenAI-compatible) entry should be removed" - ); - assert_eq!( - anthropic, - vec!["acme"], - "the shadowed anthropic_compatible entry should survive" - ); - - // A second removal deletes the entry that took over. - remove_compatible_provider(&mut settings, "acme"); - let (_, anthropic) = compatible_provider_keys(&settings); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_leaves_other_providers_untouched() { - let mut settings = settings_with_compatible_providers(&["acme", "globex"], &["initech"]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, vec!["globex"]); - assert_eq!(anthropic, vec!["initech"]); - } -} 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 deleted file mode 100644 index d1f281bbca44c3..00000000000000 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ /dev/null @@ -1,1201 +0,0 @@ -use std::sync::Arc; - -use anyhow::Result; -use fs::Fs; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt, -}; -use itertools::Itertools as _; -use language_model::LanguageModelRegistry; -use language_models::provider::open_ai_compatible::{ - AvailableModel as OpenAiCompatibleAvailableModel, - ModelCapabilities as OpenAiCompatibleModelCapabilities, -}; -use settings::{ - AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, - AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, OpenAiReasoningEffort, - update_settings_file, -}; -use ui::{ - Banner, Checkbox, ContextMenu, ContextMenuEntry, DropdownMenu, DropdownStyle, IconPosition, - KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, -}; -use ui_input::InputField; -use workspace::{ModalView, Workspace}; - -fn single_line_input( - label: impl Into, - placeholder: &str, - text: Option<&str>, - tab_index: isize, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| { - let input = InputField::new(window, cx, placeholder) - .label(label) - .tab_index(tab_index) - .tab_stop(true); - - if let Some(text) = text { - input.set_text(text, window, cx); - } - input - }) -} - -#[derive(Clone, Copy)] -pub enum LlmCompatibleProvider { - OpenAi, - Anthropic, -} - -impl LlmCompatibleProvider { - fn name(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "OpenAI", - LlmCompatibleProvider::Anthropic => "Anthropic", - } - } - - fn api_url(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1", - LlmCompatibleProvider::Anthropic => "https://api.anthropic.com", - } - } - - fn description(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "This provider will use an OpenAI compatible API.", - LlmCompatibleProvider::Anthropic => { - "This provider will use an Anthropic Messages compatible API." - } - } - } - - fn is_open_ai(&self) -> bool { - matches!(self, LlmCompatibleProvider::OpenAi) - } -} - -struct AddLlmProviderInput { - provider_name: Entity, - api_url: Entity, - api_key: Entity, - models: Vec, -} - -impl AddLlmProviderInput { - fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self { - let provider_name = - single_line_input("Provider Name", provider.name(), None, 1, window, cx); - let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx); - let api_key = cx.new(|cx| { - InputField::new( - window, - cx, - "000000000000000000000000000000000000000000000000", - ) - .label("API Key") - .tab_index(3) - .tab_stop(true) - .masked(true) - }); - - Self { - provider_name, - api_url, - api_key, - models: vec![ModelInput::new(0, window, cx)], - } - } - - fn add_model(&mut self, window: &mut Window, cx: &mut App) { - let model_index = self.models.len(); - self.models.push(ModelInput::new(model_index, window, cx)); - } - - fn remove_model(&mut self, index: usize) { - self.models.remove(index); - } -} - -struct ModelCapabilityToggles { - pub supports_tools: ToggleState, - pub supports_images: ToggleState, - pub supports_parallel_tool_calls: ToggleState, - pub supports_prompt_cache_key: ToggleState, - pub supports_chat_completions: ToggleState, - pub supports_thinking: ToggleState, - pub interleaved_reasoning: ToggleState, - pub max_tokens_parameter: ToggleState, -} - -struct ModelInput { - name: Entity, - max_completion_tokens: Entity, - max_output_tokens: Entity, - max_tokens: Entity, - reasoning_effort: OpenAiReasoningEffort, - capabilities: ModelCapabilityToggles, -} - -impl ModelInput { - fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self { - let base_tab_index = (3 + (model_index * 4)) as isize; - - let model_name = single_line_input( - "Model Name", - "e.g. gpt-5, claude-opus-4, gemini-2.5-pro", - None, - base_tab_index + 1, - window, - cx, - ); - let max_completion_tokens = single_line_input( - "Max Completion Tokens", - "200000", - Some("200000"), - base_tab_index + 2, - window, - cx, - ); - let max_output_tokens = single_line_input( - "Max Output Tokens", - "Max Output Tokens", - Some("32000"), - base_tab_index + 3, - window, - cx, - ); - let max_tokens = single_line_input( - "Max Tokens", - "Max Tokens", - Some("200000"), - base_tab_index + 4, - window, - cx, - ); - - let OpenAiCompatibleModelCapabilities { - tools, - images, - parallel_tool_calls, - prompt_cache_key, - chat_completions, - interleaved_reasoning, - max_tokens_parameter, - .. - } = OpenAiCompatibleModelCapabilities::default(); - - Self { - name: model_name, - max_completion_tokens, - max_output_tokens, - max_tokens, - capabilities: ModelCapabilityToggles { - supports_tools: tools.into(), - supports_images: images.into(), - supports_parallel_tool_calls: parallel_tool_calls.into(), - supports_prompt_cache_key: prompt_cache_key.into(), - supports_chat_completions: chat_completions.into(), - supports_thinking: ToggleState::Unselected, - interleaved_reasoning: interleaved_reasoning.into(), - max_tokens_parameter: max_tokens_parameter.into(), - }, - reasoning_effort: OpenAiReasoningEffort::Medium, - } - } - - fn parse_name(&self, cx: &App) -> Result { - let name = self.name.read(cx).text(cx); - if name.is_empty() { - return Err(SharedString::from("Model Name cannot be empty")); - } - Ok(name) - } - - fn parse_open_ai_compatible( - &self, - cx: &App, - ) -> Result { - Ok(OpenAiCompatibleAvailableModel { - name: self.parse_name(cx)?, - display_name: None, - max_completion_tokens: Some(parse_u64_field( - &self.max_completion_tokens, - "Max Completion Tokens", - cx, - )?), - max_output_tokens: Some(parse_u64_field( - &self.max_output_tokens, - "Max Output Tokens", - cx, - )?), - max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - reasoning_effort: { - if self.capabilities.supports_thinking.selected() { - Some(self.reasoning_effort) - } else { - None - } - }, - capabilities: OpenAiCompatibleModelCapabilities { - tools: self.capabilities.supports_tools.selected(), - images: self.capabilities.supports_images.selected(), - parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), - prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), - chat_completions: self.capabilities.supports_chat_completions.selected(), - interleaved_reasoning: self.capabilities.supports_thinking.selected() - && self.capabilities.supports_chat_completions.selected() - && self.capabilities.interleaved_reasoning.selected(), - max_tokens_parameter: self.capabilities.supports_chat_completions.selected() - && self.capabilities.max_tokens_parameter.selected(), - }, - }) - } - - fn parse_anthropic_compatible( - &self, - cx: &App, - ) -> Result { - Ok(AnthropicCompatibleAvailableModel { - name: self.parse_name(cx)?, - display_name: None, - max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - tool_override: None, - max_output_tokens: Some(parse_u64_field( - &self.max_output_tokens, - "Max Output Tokens", - cx, - )?), - default_temperature: None, - extra_beta_headers: Vec::new(), - mode: None, - capabilities: AnthropicCompatibleModelCapabilities { - tools: self.capabilities.supports_tools.selected(), - images: self.capabilities.supports_images.selected(), - prompt_caching: false, - }, - }) - } -} - -fn parse_u64_field( - field: &Entity, - field_name: &str, - cx: &App, -) -> Result { - field - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from(format!("{field_name} must be a number"))) -} - -enum ParsedModels { - OpenAi(Vec), - Anthropic(Vec), -} - -impl ParsedModels { - fn model_names(&self) -> impl Iterator { - match self { - ParsedModels::OpenAi(models) => { - itertools::Either::Left(models.iter().map(|model| model.name.as_str())) - } - ParsedModels::Anthropic(models) => { - itertools::Either::Right(models.iter().map(|model| model.name.as_str())) - } - } - } -} - -fn save_provider_to_settings( - provider: LlmCompatibleProvider, - input: &AddLlmProviderInput, - cx: &mut App, -) -> Task> { - let provider_name: Arc = input.provider_name.read(cx).text(cx).into(); - if provider_name.is_empty() { - return Task::ready(Err("Provider Name cannot be empty".into())); - } - - if LanguageModelRegistry::read_global(cx) - .providers() - .iter() - .any(|provider| { - provider.id().0.as_ref() == provider_name.as_ref() - || provider.name().0.as_ref() == provider_name.as_ref() - }) - { - return Task::ready(Err( - "Provider Name is already taken by another provider".into() - )); - } - - let api_url = input.api_url.read(cx).text(cx); - if api_url.is_empty() { - return Task::ready(Err("API URL cannot be empty".into())); - } - - let api_key = input.api_key.read(cx).text(cx); - if api_key.is_empty() { - return Task::ready(Err("API Key cannot be empty".into())); - } - - let models = match provider { - LlmCompatibleProvider::OpenAi => input - .models - .iter() - .map(|model| model.parse_open_ai_compatible(cx)) - .collect::, _>>() - .map(ParsedModels::OpenAi), - LlmCompatibleProvider::Anthropic => input - .models - .iter() - .map(|model| model.parse_anthropic_compatible(cx)) - .collect::, _>>() - .map(ParsedModels::Anthropic), - }; - let models = match models { - Ok(models) => models, - Err(error) => return Task::ready(Err(error)), - }; - - if !models.model_names().all_unique() { - return Task::ready(Err("Model Names must be unique".into())); - } - - let fs = ::global(cx); - let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes()); - cx.spawn(async move |cx| { - task.await - .map_err(|_| SharedString::from("Failed to write API key to keychain"))?; - cx.update(|cx| { - update_settings_file(fs, cx, move |settings, _cx| { - let language_models = settings.language_models.get_or_insert_default(); - match models { - ParsedModels::OpenAi(available_models) => { - language_models - .openai_compatible - .get_or_insert_default() - .insert( - provider_name, - OpenAiCompatibleSettingsContent { - api_url, - available_models, - custom_headers: None, - }, - ); - } - ParsedModels::Anthropic(available_models) => { - language_models - .anthropic_compatible - .get_or_insert_default() - .insert( - provider_name, - AnthropicCompatibleSettingsContent { - api_url, - available_models, - custom_headers: None, - }, - ); - } - } - }); - }); - Ok(()) - }) -} - -pub struct AddLlmProviderModal { - provider: LlmCompatibleProvider, - input: AddLlmProviderInput, - scroll_handle: ScrollHandle, - focus_handle: FocusHandle, - last_error: Option, -} - -impl AddLlmProviderModal { - pub fn toggle( - provider: LlmCompatibleProvider, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - workspace.toggle_modal(window, cx, |window, cx| Self::new(provider, window, cx)); - } - - fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut Context) -> Self { - Self { - input: AddLlmProviderInput::new(provider, window, cx), - provider, - last_error: None, - focus_handle: cx.focus_handle(), - scroll_handle: ScrollHandle::new(), - } - } - - fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { - let task = save_provider_to_settings(self.provider, &self.input, cx); - cx.spawn(async move |this, cx| { - let result = task.await; - this.update(cx, |this, cx| match result { - Ok(_) => { - cx.emit(DismissEvent); - } - Err(error) => { - this.last_error = Some(error); - cx.notify(); - } - }) - }) - .detach_and_log_err(cx); - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn render_model_section( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - v_flex() - .mt_1() - .gap_2() - .child( - h_flex() - .justify_between() - .child(Label::new("Models").size(LabelSize::Small)) - .child( - Button::new("add-model", "Add Model") - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.input.add_model(window, cx); - cx.notify(); - })), - ), - ) - .children( - self.input - .models - .iter() - .enumerate() - .map(|(ix, _)| self.render_model(ix, window, cx)), - ) - } - - fn render_open_ai_reasoning_settings( - &self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let model = &self.input.models[ix]; - let selected_effort = model.reasoning_effort; - let supports_thinking = model.capabilities.supports_thinking; - let supports_chat_completions = model.capabilities.supports_chat_completions; - let interleaved_reasoning = model.capabilities.interleaved_reasoning; - let weak_self = cx.weak_entity(); - - let effort_menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| { - for effort in OpenAiReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE { - let is_selected = effort == selected_effort; - let weak_self = weak_self.clone(); - menu.push_item( - ContextMenuEntry::new(effort.label()) - .toggleable(IconPosition::End, is_selected) - .handler(move |_window, cx| { - weak_self - .update(cx, |this, cx| { - this.input.models[ix].reasoning_effort = effort; - cx.notify(); - }) - .ok(); - }), - ); - } - - menu - }); - - v_flex() - .gap_1() - .child( - Checkbox::new(("supports-thinking", ix), supports_thinking) - .label("Supports thinking") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_thinking = *checked; - cx.notify(); - })), - ) - .when(supports_thinking.selected(), |parent| { - parent - .child( - v_flex() - .gap_1() - .child(Label::new("Default reasoning effort").size(LabelSize::Small)) - .child( - DropdownMenu::new( - ElementId::Name( - format!("reasoning-effort-selector-{ix}").into(), - ), - selected_effort.label(), - effort_menu, - ) - .style(DropdownStyle::Outlined) - .trigger_size(ButtonSize::Compact) - .full_width(true) - .aria_label("Default reasoning effort"), - ), - ) - .when(supports_chat_completions.selected(), |parent| { - parent.child( - Checkbox::new(("interleaved-reasoning", ix), interleaved_reasoning) - .label("Preserves thinking in chat history") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.interleaved_reasoning = - *checked; - cx.notify(); - })), - ) - }) - }) - } - - fn render_model( - &self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let has_more_than_one_model = self.input.models.len() > 1; - let is_open_ai = self.provider.is_open_ai(); - let model = &self.input.models[ix]; - - v_flex() - .p_2() - .gap_2() - .rounded_sm() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.6)) - .bg(cx.theme().colors().element_active.opacity(0.15)) - .child(model.name.clone()) - .child( - h_flex() - .gap_2() - .when(is_open_ai, |parent| { - parent.child(model.max_completion_tokens.clone()) - }) - .child(model.max_output_tokens.clone()), - ) - .child(model.max_tokens.clone()) - .child( - v_flex() - .gap_1() - .child( - Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools) - .label("Supports tools") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_tools = *checked; - cx.notify(); - })), - ) - .child( - Checkbox::new(("supports-images", ix), model.capabilities.supports_images) - .label("Supports images") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_images = *checked; - cx.notify(); - })), - ) - .when(is_open_ai, |parent| { - parent - .child( - Checkbox::new( - ("supports-parallel-tool-calls", ix), - model.capabilities.supports_parallel_tool_calls, - ) - .label("Supports parallel_tool_calls") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_parallel_tool_calls = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-prompt-cache-key", ix), - model.capabilities.supports_prompt_cache_key, - ) - .label("Supports prompt_cache_key") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_prompt_cache_key = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-chat-completions", ix), - model.capabilities.supports_chat_completions, - ) - .label("Supports /chat/completions") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_chat_completions = *checked; - cx.notify(); - }, - )), - ) - .when( - model.capabilities.supports_chat_completions.selected(), - |parent| { - parent.child( - Checkbox::new( - ("max-tokens-parameter", ix), - model.capabilities.max_tokens_parameter, - ) - .label("Uses max_tokens for output limit") - .on_click( - cx.listener(move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .max_tokens_parameter = *checked; - cx.notify(); - }), - ), - ) - }, - ) - .child(self.render_open_ai_reasoning_settings(ix, window, cx)) - }), - ) - .when(has_more_than_one_model, |this| { - this.child( - Button::new(("remove-model", ix), "Remove Model") - .start_icon( - Icon::new(IconName::Trash) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .full_width() - .on_click(cx.listener(move |this, _, _window, cx| { - this.input.remove_model(ix); - cx.notify(); - })), - ) - }) - } - - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { - window.focus_next(cx); - } - - fn on_tab_prev( - &mut self, - _: &menu::SelectPrevious, - window: &mut Window, - cx: &mut Context, - ) { - window.focus_prev(cx); - } -} - -impl EventEmitter for AddLlmProviderModal {} - -impl Focusable for AddLlmProviderModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl ModalView for AddLlmProviderModal {} - -impl Render for AddLlmProviderModal { - fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - let window_size = window.viewport_size(); - let rem_size = window.rem_size(); - let is_large_window = window_size.height / rem_size > rems_from_px(600.).0; - - let modal_max_height = if is_large_window { - rems_from_px(450.) - } else { - rems_from_px(200.) - }; - - v_flex() - .id("add-llm-provider-modal") - .key_context("AddLlmProviderModal") - .w(rems(34.)) - .elevation_3(cx) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::on_tab)) - .on_action(cx.listener(Self::on_tab_prev)) - .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window, cx); - })) - .child( - Modal::new("configure-context-server", None) - .header( - ModalHeader::new() - .headline("Add LLM Provider") - .description(self.provider.description()), - ) - .when_some(self.last_error.clone(), |this, error| { - this.section( - Section::new().child( - Banner::new() - .severity(Severity::Warning) - .child(div().text_xs().child(error)), - ), - ) - }) - .child( - div() - .size_full() - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - .child( - v_flex() - .id("modal_content") - .size_full() - .tab_group() - .max_h(modal_max_height) - .pl_3() - .pr_4() - .pb_2() - .gap_2() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .child(self.input.provider_name.clone()) - .child(self.input.api_url.clone()) - .child(self.input.api_key.clone()) - .child(self.render_model_section(window, cx)), - ), - ) - .footer( - ModalFooter::new().end_slot( - h_flex() - .gap_1() - .child( - Button::new("cancel", "Cancel") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.cancel(&menu::Cancel, window, cx) - })), - ) - .child( - Button::new("save-server", "Save Provider") - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.confirm(&menu::Confirm, window, cx) - })), - ), - ), - ), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::FakeFs; - use gpui::{TestAppContext, VisualTestContext}; - use language_model::{ - LanguageModelProviderId, LanguageModelProviderName, - fake_provider::FakeLanguageModelProvider, - }; - use project::Project; - use settings::SettingsStore; - use util::path; - use workspace::MultiWorkspace; - - #[gpui::test] - async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - for provider in [ - LlmCompatibleProvider::OpenAi, - LlmCompatibleProvider::Anthropic, - ] { - assert_eq!( - save_provider_validation_errors(provider, "", "someurl", "somekey", vec![], cx) - .await, - Some("Provider Name cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "", - "somekey", - vec![], - cx - ) - .await, - Some("API URL cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "", - vec![], - cx - ) - .await, - Some("API Key cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Model Name cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "abc", "200000", "32000")], - cx, - ) - .await, - Some("Max Tokens must be a number".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "200000", "abc")], - cx, - ) - .await, - Some("Max Output Tokens must be a number".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![ - ("somemodel", "200000", "200000", "32000"), - ("somemodel", "200000", "200000", "32000"), - ], - cx, - ) - .await, - Some("Model Names must be unique".into()) - ); - } - - // Max Completion Tokens is only used by OpenAI-compatible providers. - assert_eq!( - save_provider_validation_errors( - LlmCompatibleProvider::OpenAi, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "abc", "32000")], - cx, - ) - .await, - Some("Max Completion Tokens must be a number".into()) - ); - } - - #[gpui::test] - async fn test_save_provider_name_conflict(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|_window, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.register_provider( - Arc::new(FakeLanguageModelProvider::new( - LanguageModelProviderId::new("someprovider"), - LanguageModelProviderName::new("Some Provider"), - )), - cx, - ); - }); - }); - - assert_eq!( - save_provider_validation_errors( - LlmCompatibleProvider::OpenAi, - "someprovider", - "someurl", - "someapikey", - vec![("somemodel", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Provider Name is already taken by another provider".into()) - ); - } - - #[gpui::test] - async fn test_model_input_default_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - assert_eq!( - model_input.capabilities.supports_tools, - ToggleState::Selected - ); - assert_eq!( - model_input.capabilities.supports_images, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_parallel_tool_calls, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_prompt_cache_key, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_chat_completions, - ToggleState::Selected - ); - assert_eq!( - model_input.capabilities.supports_thinking, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.interleaved_reasoning, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.max_tokens_parameter, - ToggleState::Unselected - ); - assert_eq!(model_input.reasoning_effort, OpenAiReasoningEffort::Medium); - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(!parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(parsed_model.capabilities.chat_completions); - assert!(!parsed_model.capabilities.interleaved_reasoning); - assert!(!parsed_model.capabilities.max_tokens_parameter); - assert_eq!(parsed_model.reasoning_effort, None); - }); - } - - #[gpui::test] - async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - model_input.capabilities.supports_tools = ToggleState::Unselected; - model_input.capabilities.supports_images = ToggleState::Unselected; - model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected; - model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; - model_input.capabilities.supports_chat_completions = ToggleState::Unselected; - model_input.capabilities.supports_thinking = ToggleState::Unselected; - model_input.capabilities.interleaved_reasoning = ToggleState::Selected; - model_input.capabilities.max_tokens_parameter = ToggleState::Selected; - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert!(!parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(!parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(!parsed_model.capabilities.chat_completions); - assert!(!parsed_model.capabilities.interleaved_reasoning); - assert!(!parsed_model.capabilities.max_tokens_parameter); - assert_eq!(parsed_model.reasoning_effort, None); - }); - } - - #[gpui::test] - async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - model_input.capabilities.supports_tools = ToggleState::Selected; - model_input.capabilities.supports_images = ToggleState::Unselected; - model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected; - model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; - model_input.capabilities.supports_chat_completions = ToggleState::Selected; - model_input.capabilities.supports_thinking = ToggleState::Selected; - model_input.capabilities.interleaved_reasoning = ToggleState::Selected; - model_input.capabilities.max_tokens_parameter = ToggleState::Selected; - model_input.reasoning_effort = OpenAiReasoningEffort::XHigh; - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert_eq!(parsed_model.name, "somemodel"); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(parsed_model.capabilities.chat_completions); - assert!(parsed_model.capabilities.interleaved_reasoning); - assert!(parsed_model.capabilities.max_tokens_parameter); - assert_eq!( - parsed_model.reasoning_effort, - Some(OpenAiReasoningEffort::XHigh) - ); - }); - } - - #[gpui::test] - async fn test_model_input_parse_anthropic_compatible(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); - assert_eq!(parsed_model.name, "somemodel"); - assert_eq!(parsed_model.max_tokens, 200000); - assert_eq!(parsed_model.max_output_tokens, Some(32000)); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - - model_input.capabilities.supports_tools = ToggleState::Unselected; - model_input.capabilities.supports_images = ToggleState::Selected; - - let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); - assert!(!parsed_model.capabilities.tools); - assert!(parsed_model.capabilities.images); - }); - } - - async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext { - cx.update(|cx| { - let store = SettingsStore::test(cx); - cx.set_global(store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - - language_model::init(cx); - editor::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - cx - } - - async fn save_provider_validation_errors( - provider: LlmCompatibleProvider, - provider_name: &str, - api_url: &str, - api_key: &str, - models: Vec<(&str, &str, &str, &str)>, - cx: &mut VisualTestContext, - ) -> Option { - fn set_text(input: &Entity, text: &str, window: &mut Window, cx: &mut App) { - input.update(cx, |input, cx| { - input.set_text(text, window, cx); - }); - } - - let task = cx.update(|window, cx| { - let mut input = AddLlmProviderInput::new(provider, window, cx); - set_text(&input.provider_name, provider_name, window, cx); - set_text(&input.api_url, api_url, window, cx); - set_text(&input.api_key, api_key, window, cx); - - for (i, (name, max_tokens, max_completion_tokens, max_output_tokens)) in - models.iter().enumerate() - { - if i >= input.models.len() { - input.models.push(ModelInput::new(i, window, cx)); - } - let model = &mut input.models[i]; - set_text(&model.name, name, window, cx); - set_text(&model.max_tokens, max_tokens, window, cx); - set_text( - &model.max_completion_tokens, - max_completion_tokens, - window, - cx, - ); - set_text(&model.max_output_tokens, max_output_tokens, window, cx); - } - save_provider_to_settings(provider, &input, cx) - }); - - task.await.err() - } -} 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 5ceb13d594c84e..6a32b488605f6b 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 @@ -3,10 +3,10 @@ use collections::HashMap; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; +use extension_host::ExtensionStore; use gpui::{ AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, - Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, - prelude::*, + Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; @@ -31,12 +31,7 @@ use ui::{ use util::ResultExt as _; use workspace::{ModalView, Workspace}; -use crate::{AddContextServer, ContextServerType}; - enum ConfigurationTarget { - New { - server_type: ContextServerType, - }, Existing { id: ContextServerId, command: ContextServerCommand, @@ -55,14 +50,15 @@ enum ConfigurationTarget { }, } +enum ExistingServerType { + Local, + Remote, +} + enum ConfigurationSource { - New { - editor: Entity, - server_type: ContextServerType, - }, Existing { editor: Entity, - server_type: ContextServerType, + server_type: ExistingServerType, }, Extension { id: ContextServerId, @@ -78,10 +74,6 @@ impl ConfigurationSource { !matches!(self, ConfigurationSource::Extension { editor: None, .. }) } - fn is_new(&self) -> bool { - matches!(self, ConfigurationSource::New { .. }) - } - fn from_target( target: ConfigurationTarget, language_registry: Arc, @@ -108,18 +100,6 @@ impl ConfigurationSource { } match target { - ConfigurationTarget::New { server_type } => ConfigurationSource::New { - editor: create_editor( - match server_type { - ContextServerType::Remote => context_server_http_input(None), - ContextServerType::Local => context_server_input(None), - }, - jsonc_language, - window, - cx, - ), - server_type, - }, ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing { editor: create_editor( context_server_input(Some((id, command))), @@ -127,7 +107,7 @@ impl ConfigurationSource { window, cx, ), - server_type: ContextServerType::Local, + server_type: ExistingServerType::Local, }, ConfigurationTarget::ExistingHttp { id, @@ -141,7 +121,7 @@ impl ConfigurationSource { window, cx, ), - server_type: ContextServerType::Remote, + server_type: ExistingServerType::Remote, }, ConfigurationTarget::Extension { @@ -179,15 +159,11 @@ impl ConfigurationSource { fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> { match self { - ConfigurationSource::New { - editor, - server_type, - } - | ConfigurationSource::Existing { + ConfigurationSource::Existing { editor, server_type, } => match *server_type { - ContextServerType::Remote => { + ExistingServerType::Remote => { parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| { ( id, @@ -201,7 +177,7 @@ impl ConfigurationSource { ) }) } - ContextServerType::Local => { + ExistingServerType::Local => { parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { ( id, @@ -402,7 +378,12 @@ fn resolve_context_server_extension( return Task::ready(None); }; - let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx); + let extension = ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .iter() + .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) + .map(|(id, entry)| (id.clone(), entry.manifest.clone())); cx.spawn(async move |cx| { let installation = descriptor .configuration(worktree_store, cx) @@ -453,13 +434,10 @@ impl ConfigureContextServerModal { target: &ConfigurationTarget, cx: &App, ) -> State { - let Some(server_id) = (match target { + let server_id = match target { ConfigurationTarget::Existing { id, .. } | ConfigurationTarget::ExistingHttp { id, .. } - | ConfigurationTarget::Extension { id, .. } => Some(id), - ConfigurationTarget::New { .. } => None, - }) else { - return State::Idle; + | ConfigurationTarget::Extension { id, .. } => id, }; match context_server_store.read(cx).status_for_server(server_id) { @@ -484,32 +462,6 @@ impl ConfigureContextServerModal { } } - pub fn register( - workspace: &mut Workspace, - language_registry: Arc, - _window: Option<&mut Window>, - _cx: &mut Context, - ) { - workspace.register_action({ - move |_workspace, action: &AddContextServer, window, cx| { - let workspace_handle = cx.weak_entity(); - let language_registry = language_registry.clone(); - let server_type = action.context_server_type; - window - .spawn(cx, async move |cx| { - Self::show_modal( - ConfigurationTarget::New { server_type }, - language_registry, - workspace_handle, - cx, - ) - .await - }) - .detach_and_log_err(cx); - } - }); - } - pub fn show_modal_for_existing_server( server_id: ContextServerId, language_registry: Arc, @@ -594,12 +546,11 @@ impl ConfigureContextServerModal { workspace: workspace_handle, state: Self::initial_state(&context_server_store, &target, cx), - original_server_id: match &target { - ConfigurationTarget::Existing { id, .. } => Some(id.clone()), - ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), - ConfigurationTarget::Extension { id, .. } => Some(id.clone()), - ConfigurationTarget::New { .. } => None, - }, + original_server_id: Some(match &target { + ConfigurationTarget::Existing { id, .. } + | ConfigurationTarget::ExistingHttp { id, .. } + | ConfigurationTarget::Extension { id, .. } => id.clone(), + }), source: ConfigurationSource::from_target( target, language_registry, @@ -829,7 +780,6 @@ impl ModalView for ConfigureContextServerModal {} impl Focusable for ConfigureContextServerModal { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.source { - ConfigurationSource::New { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Extension { editor, .. } => editor .as_ref() @@ -844,7 +794,6 @@ impl EventEmitter for ConfigureContextServerModal {} impl ConfigureContextServerModal { fn render_modal_header(&self) -> ModalHeader { let text: SharedString = match &self.source { - ConfigurationSource::New { .. } => "Add MCP Server".into(), ConfigurationSource::Existing { .. } => "Configure MCP Server".into(), ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(), }; @@ -875,79 +824,8 @@ impl ConfigureContextServerModal { } } - fn render_tab_bar(&self, cx: &mut Context) -> Option { - let is_http = match &self.source { - ConfigurationSource::New { server_type, .. } => { - *server_type == ContextServerType::Remote - } - _ => return None, - }; - - let tab = |label: &'static str, active: bool| { - div() - .id(label) - .cursor_pointer() - .p_1() - .text_sm() - .border_b_1() - .when_else( - active, - |this| this.border_color(cx.theme().colors().border_focused), - |this| { - this.border_color(gpui::transparent_black()) - .text_color(cx.theme().colors().text_muted) - .hover(|s| s.text_color(cx.theme().colors().text)) - }, - ) - .child(label) - }; - - Some( - h_flex() - .pt_1() - .mb_2p5() - .gap_1() - .border_b_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .child( - tab("Local", !is_http).on_click(cx.listener(|this, _, window, cx| { - if let ConfigurationSource::New { - editor, - server_type, - } = &mut this.source - && *server_type != ContextServerType::Local - { - *server_type = ContextServerType::Local; - let new_text = context_server_input(None); - editor.update(cx, |editor, cx| { - editor.set_text(new_text, window, cx); - }); - } - })), - ) - .child( - tab("Remote", is_http).on_click(cx.listener(|this, _, window, cx| { - if let ConfigurationSource::New { - editor, - server_type, - } = &mut this.source - && *server_type != ContextServerType::Remote - { - *server_type = ContextServerType::Remote; - let new_text = context_server_http_input(None); - editor.update(cx, |editor, cx| { - editor.set_text(new_text, window, cx); - }); - } - })), - ) - .into_any_element(), - ) - } - fn render_modal_content(&self, cx: &App) -> AnyElement { let editor = match &self.source { - ConfigurationSource::New { editor, .. } => editor, ConfigurationSource::Existing { editor, .. } => editor, ConfigurationSource::Extension { editor, .. } => { let Some(editor) = editor else { @@ -1047,24 +925,15 @@ impl ConfigureContextServerModal { ), ) .children(self.source.has_configuration_options().then(|| { - Button::new( - "add-server", - if self.source.is_new() { - "Add Server" - } else { - "Configure Server" - }, - ) - .disabled(is_busy) - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click( - cx.listener(|this, _event, _window, cx| { + Button::new("configure-server", "Configure Server") + .disabled(is_busy) + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _event, _window, cx| { this.confirm(&menu::Confirm, cx) - }), - ) + })) })), ) } @@ -1272,7 +1141,6 @@ impl Render for ConfigureContextServerModal { .overflow_y_scroll() .track_scroll(&self.scroll_handle) .child(self.render_modal_description(window, cx)) - .children(self.render_tab_bar(cx)) .child(self.render_modal_content(cx)) .child(match &self.state { State::Idle => div(), diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs deleted file mode 100644 index 5115e2f70c0ae8..00000000000000 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs +++ /dev/null @@ -1,175 +0,0 @@ -use agent::ContextServerRegistry; -use collections::HashMap; -use context_server::ContextServerId; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Window, prelude::*, -}; -use ui::{Divider, DividerColor, Modal, ModalHeader, WithScrollbar, prelude::*}; -use workspace::{ModalView, Workspace}; - -pub struct ConfigureContextServerToolsModal { - context_server_id: ContextServerId, - context_server_registry: Entity, - focus_handle: FocusHandle, - expanded_tools: HashMap, - scroll_handle: ScrollHandle, -} - -impl ConfigureContextServerToolsModal { - fn new( - context_server_id: ContextServerId, - context_server_registry: Entity, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - context_server_id, - context_server_registry, - focus_handle: cx.focus_handle(), - expanded_tools: HashMap::default(), - scroll_handle: ScrollHandle::new(), - } - } - - pub fn toggle( - context_server_id: ContextServerId, - context_server_registry: Entity, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - workspace.toggle_modal(window, cx, |window, cx| { - Self::new(context_server_id, context_server_registry, window, cx) - }); - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent) - } - - fn render_modal_content( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let tools = self - .context_server_registry - .read(cx) - .tools_for_server(&self.context_server_id) - .collect::>(); - - div() - .size_full() - .pb_2() - .child( - v_flex() - .id("modal_content") - .px_2() - .gap_1() - .max_h_128() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .children(tools.iter().enumerate().flat_map(|(index, tool)| { - let tool_name = tool.name(); - let is_expanded = self - .expanded_tools - .get(tool_name.as_ref()) - .copied() - .unwrap_or(false); - - let icon = if is_expanded { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - let mut items = vec![ - v_flex() - .child( - h_flex() - .id(format!("tool-header-{}", index)) - .py_1() - .pl_1() - .pr_2() - .w_full() - .justify_between() - .rounded_sm() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child( - Label::new(tool_name.clone()) - .buffer_font(cx) - .size(LabelSize::Small), - ) - .child( - Icon::new(icon) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(cx.listener({ - move |this, _event, _window, _cx| { - let current = this - .expanded_tools - .get(tool_name.as_ref()) - .copied() - .unwrap_or(false); - this.expanded_tools - .insert(tool_name.clone(), !current); - _cx.notify(); - } - })), - ) - .when(is_expanded, |this| { - this.child( - Label::new(tool.description()).color(Color::Muted).mx_1(), - ) - }) - .into_any_element(), - ]; - - if index < tools.len() - 1 { - items.push( - h_flex() - .w_full() - .child(Divider::horizontal().color(DividerColor::BorderVariant)) - .into_any_element(), - ); - } - - items - })), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - .into_any_element() - } -} - -impl ModalView for ConfigureContextServerToolsModal {} - -impl Focusable for ConfigureContextServerToolsModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for ConfigureContextServerToolsModal {} - -impl Render for ConfigureContextServerToolsModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .key_context("ContextServerToolsModal") - .occlude() - .elevation_3(cx) - .w(rems(34.)) - .on_action(cx.listener(Self::cancel)) - .track_focus(&self.focus_handle) - .child( - Modal::new("configure-context-server-tools", None::) - .header( - ModalHeader::new() - .headline(format!("Tools from {}", self.context_server_id.0)) - .show_dismiss_button(true), - ) - .child(self.render_modal_content(window, cx)), - ) - } -} diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 8a329bdc7c84fa..524cb0b07473f4 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -6,8 +6,8 @@ use anyhow::Result; use buffer_diff::DiffHunkStatus; use collections::{HashMap, HashSet}; use editor::{ - Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot, - SelectionEffects, SplittableEditor, ToPoint, + DiffHunkDelegate, Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, + MultiBufferSnapshot, ResolvedDiffHunks, SelectionEffects, SplittableEditor, ToPoint, actions::{GoToHunk, GoToPreviousHunk}, multibuffer_context_lines, scroll::Autoscroll, @@ -28,12 +28,12 @@ use std::{ ops::Range, sync::Arc, }; -use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider}; -use util::ResultExt; +use ui::{CommonAnimationExt, Divider, IconButtonShape, KeyBinding, Tooltip, prelude::*}; +use util::{ResultExt, truncate_and_trailoff}; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, - item::{ItemEvent, SaveOptions, TabContentParams}, + item::{ItemEvent, SaveOptions, TabContentParams, TabTooltipContent}, searchable::SearchableItemHandle, }; use zed_actions::assistant::ToggleFocus; @@ -101,8 +101,7 @@ impl AgentDiffPane { cx, ); diff_display_editor - .set_render_diff_hunk_controls(diff_hunk_controls(&thread, workspace.clone()), cx); - diff_display_editor.set_render_diff_hunks_as_unstaged(cx); + .set_diff_hunk_delegate(Some(agent_diff_delegate(&thread, workspace.clone())), cx); diff_display_editor.update_editors(cx, |editor, _cx| { editor.register_addon(AgentDiffAddon); }); @@ -529,23 +528,33 @@ impl Item for AgentDiffPane { .update(cx, |editor, cx| editor.navigate(data, window, cx)) } - fn tab_tooltip_text(&self, _: &App) -> Option { - Some("Agent Diff".into()) + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + let label_content = self.tab_content_text(params.detail.unwrap_or_default(), cx); + + Label::new(label_content) + .when(!params.selected, |this| this.color(Color::Muted)) + .into_any_element() } - fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + fn tab_tooltip_content(&self, cx: &App) -> Option { let title = self.thread.read(cx).title(); - Label::new(if let Some(title) = title { - format!("Review: {}", title) - } else { - "Review".to_string() - }) - .color(if params.selected { - Color::Default - } else { - Color::Muted - }) - .into_any_element() + + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let title = title.map(|title| title.to_string()); + + move |_, _| { + v_flex() + .child(Label::new( + title.clone().unwrap_or_else(|| "Review".to_string()), + )) + .child( + Label::new("Agent Diff") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) } fn telemetry_event_text(&self) -> Option<&'static str> { @@ -667,8 +676,11 @@ impl Item for AgentDiffPane { }); } - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Agent Diff".into() + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + match self.thread.read(cx).title() { + Some(title) => format!("Review: {}", truncate_and_trailoff(&title, 20)).into(), + None => "Review".into(), + } } } @@ -722,29 +734,68 @@ impl Render for AgentDiffPane { } } -fn diff_hunk_controls( +struct AgentDiffDelegate { + thread: Entity, + workspace: WeakEntity, +} + +fn agent_diff_delegate( thread: &Entity, workspace: WeakEntity, -) -> editor::RenderDiffHunkControlsFn { - let thread = thread.clone(); +) -> Arc { + Arc::new(AgentDiffDelegate { + thread: thread.clone(), + workspace, + }) +} - Arc::new( - move |row, status, hunk_range, is_created_file, line_height, editor, _, cx| { - { - render_diff_hunk_controls( - row, - status, - hunk_range, - is_created_file, - line_height, - &thread, - editor, - workspace.clone(), - cx, - ) - } - }, - ) +impl DiffHunkDelegate for AgentDiffDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + render_diff_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + &self.thread, + editor, + self.workspace.clone(), + cx, + ) + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } } fn render_diff_hunk_controls( @@ -1099,7 +1150,7 @@ impl Render for AgentDiffToolbar { }), ) .into_any_element(), - vertical_divider().into_any_element(), + Divider::vertical().into_any_element(), h_flex() .gap_0p5() .child( @@ -1141,7 +1192,7 @@ impl Render for AgentDiffToolbar { .mr_1() .gap_1() .children(content) - .child(vertical_divider()) + .child(Divider::vertical()) .when_some(editor.read(cx).workspace(), |this, _workspace| { this.child( IconButton::new("review", IconName::ListTodo) @@ -1158,7 +1209,7 @@ impl Render for AgentDiffToolbar { }), ) }) - .child(vertical_divider()) + .child(Divider::vertical()) .on_action({ let editor = editor.clone(); move |_action: &OpenAgentDiff, window, cx| { @@ -1437,6 +1488,8 @@ impl AgentDiff { | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequested(_) | AcpThreadEvent::ToolAuthorizationReceived(_) + | AcpThreadEvent::ElicitationRequested(_) + | AcpThreadEvent::ElicitationResponded(_) | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::AvailableCommandsUpdated(_) | AcpThreadEvent::Retry(_) @@ -1526,7 +1579,7 @@ impl AgentDiff { for (editor, _) in self.reviewing_editors.drain() { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); @@ -1575,12 +1628,10 @@ impl AgentDiff { if previous_state.is_none() { editor.update(cx, |editor, cx| { - editor.start_temporary_diff_override(); - editor.set_render_diff_hunk_controls( - diff_hunk_controls(&thread, workspace.clone()), + editor.set_diff_hunk_delegate( + Some(agent_diff_delegate(&thread, workspace.clone())), cx, ); - editor.set_render_diff_hunks_as_unstaged(true, cx); editor.set_expand_all_diff_hunks(cx); editor.register_addon(EditorAgentDiffAddon); }); @@ -1627,7 +1678,7 @@ impl AgentDiff { if in_workspace { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4d3b06c21d3d6f..7f19aa1b751f28 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -20,14 +20,13 @@ use db::kvp::{Dismissable, KeyValueStore}; use itertools::Itertools; use project::{AgentId, ProjectItem}; use serde::{Deserialize, Serialize}; -use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, agent::{ AddSelectionToThread, ConflictContent, LogoutAgent, OpenSettings, ReauthenticateAgent, ResetAgentZoom, ResetOnboarding, ResolveConflictedFilesWithAgent, - ResolveConflictsWithAgent, ReviewBranchDiff, + ResolveConflictsWithAgent, ReviewBranchDiff, SelectAgent, }, assistant::{ FocusAgent, ManageSkills, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, Toggle, @@ -43,22 +42,23 @@ use crate::terminal_thread_metadata_store::{ TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title, terminal_title_without_prefix, }; -use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; +use crate::thread_metadata_store::{ + ThreadId, ThreadMetadata, ThreadMetadataStore, ThreadMetadataStoreEvent, WorktreePaths, +}; +use crate::{ + Agent, AgentInitialContent, AgentThreadSource, ExternalSourcePrompt, NewExternalAgentThread, + NewNativeAgentThreadFromSummary, +}; use crate::{ - AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, - LoadThreadFromClipboard, NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, - OpenAgentDiff, ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell, - ShowAllSidebarThreadMetadata, ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu, - agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, + AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, LoadThreadFromClipboard, + NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetFastModeWarnings, + ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata, + ToggleNewThreadMenu, ToggleOptionsMenu, conversation_view::{ AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings, }, ui::{AgentNotification, AgentNotificationEvent, EndTrialUpsell}, }; -use crate::{ - Agent, AgentInitialContent, AgentThreadSource, ExternalSourcePrompt, NewExternalAgentThread, - NewNativeAgentThreadFromSummary, -}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; use anyhow::{Context as _, Result, anyhow}; @@ -70,9 +70,7 @@ use cloud_api_types::Plan; use collections::HashMap; use editor::{Editor, MultiBuffer}; use extension_host::ExtensionStore; -use feature_flags::{ - AgentSettingsUiFeatureFlag, CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, -}; +use feature_flags::{CreateThreadToolFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use futures::FutureExt as _; @@ -88,6 +86,7 @@ use notifications::status_toast::StatusToast; use project::{Project, ProjectPath, Worktree}; use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file}; +use search::{BufferSearchBar, buffer_search::Deploy as DeployBufferSearch}; use terminal::Event as TerminalEvent; use terminal_view::TerminalView; use text::OffsetRangeExt; @@ -99,9 +98,10 @@ use ui::{ use util::ResultExt as _; use workspace::{ CollaboratorId, DraggedSelection, DraggedTab, MultiWorkspace, PaneKind, PathList, - SerializedPathList, ToggleSidebar, ToggleZoom, Workspace, WorkspaceId, + SerializedPathList, ToggleSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, - item::ItemEvent, + item::{ItemEvent, ItemHandle}, + panel_pane::PanelItem, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -110,6 +110,20 @@ const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; const LAST_CREATED_ENTRY_KIND_KEY: &str = "agent_panel__last_created_entry_kind"; const TERMINAL_AGENT_TELEMETRY_ID: &str = "terminal"; const TERMINAL_INIT_COMMAND_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); + +// The pty's working directory is refreshed on a background thread after each +// wakeup, so the value visible while handling that wakeup can be one change +// behind. Re-check shortly after output settles so the final cwd is persisted +// even when the shell produces no further output. +const TERMINAL_METADATA_RECHECK_DEBOUNCE: Duration = Duration::from_millis(500); +// Status scraping runs shortly after output settles rather than on every +// wakeup; a finished agent goes quiet, so pending Working -> Idle holds are +// confirmed by rescheduled scrapes instead of further output. +const TERMINAL_STATUS_SCRAPE_DEBOUNCE: Duration = Duration::from_millis(150); +const TERMINAL_STATUS_SCRAPE_LINES: usize = 40; +// The agent process starts before detection first notices it, so session +// files created in that window must still be attributed to this terminal. +const TERMINAL_SESSION_CAPTURE_SLACK: Duration = Duration::from_secs(15); const KNOWN_TERMINAL_AGENT_COMMANDS: &[&str] = &[ "agent", // Unfortunately, both Cursor cli + grok "agy", @@ -192,6 +206,7 @@ pub struct AgentPanelTerminalInfo { pub has_notification: bool, pub custom_title: Option, pub working_directory: Option, + pub agent_status: Option, } #[derive(Serialize, Deserialize)] @@ -240,7 +255,7 @@ fn project_agents_md_path( require_existing_file: bool, cx: &App, ) -> Option { - let rel_path = util::rel_path::RelPath::unix("AGENTS.md").ok()?; + let rel_path = util::rel_path::RelPath::from_unix_str("AGENTS.md").ok()?; project .read(cx) .visible_worktrees(cx) @@ -437,6 +452,14 @@ pub fn init(cx: &mut App) { cx, ); }) + .register_action(|workspace, action: &SelectAgent, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + let agent = AgentId::new(action.agent.clone()).into(); + panel.select_agent(agent, window, cx); + }); + } + }) .register_action(|workspace, action: &ManageSkills, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -1005,11 +1028,29 @@ struct AgentTerminal { working_directory: Option, created_at: DateTime, has_notification: bool, + search_bar: Option>, notification_windows: Vec>, notification_subscriptions: Vec, + metadata_recheck_task: Option>, + agent_kind: Option, + /// The agent this restored terminal relaunched, used for persistence + /// until live detection re-identifies (or rules out) the process. + restored_agent: Option, + agent_detected_at: Option, + agent_session: Option, + status_tracker: agent_detect::StatusTracker, + status_scrape_task: Option>, + session_capture_task: Option>, _subscriptions: Vec, } +/// The detected state of an agent CLI running inside a terminal thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TerminalAgentStatus { + pub agent: agent_detect::AgentKind, + pub state: agent_detect::AgentState, +} + impl AgentTerminal { fn terminal_title_for_view(view: &TerminalView, cx: &App) -> SharedString { let terminal = view.terminal().read(cx); @@ -1072,7 +1113,12 @@ impl AgentTerminal { } fn refresh_metadata(&mut self, cx: &mut App) -> bool { + let previous_terminal_title = self.last_known_terminal_title.clone(); let title_changed = self.refresh_title(cx); + // The composed title can stay the same while the underlying shell + // title changes (e.g. a custom title masks it); the raw title is + // persisted too, so treat that as a change. + let raw_title_changed = self.last_known_terminal_title != previous_terminal_title; let current_working_directory = self.view.read(cx).terminal().read(cx).working_directory(); let working_directory_changed = current_working_directory .as_ref() @@ -1080,7 +1126,7 @@ impl AgentTerminal { if working_directory_changed { self.working_directory = current_working_directory; } - title_changed || working_directory_changed + title_changed || raw_title_changed || working_directory_changed } fn custom_title(&self, cx: &App) -> Option { @@ -1134,15 +1180,10 @@ impl From for BaseView { } } -enum OverlayView { - Configuration, -} - enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), Terminal(&'a Entity), - Configuration(Option<&'a Entity>), } enum WhichFontSize { @@ -1159,14 +1200,6 @@ impl BaseView { } } -impl OverlayView { - pub fn which_font_size_used(&self) -> WhichFontSize { - match self { - OverlayView::Configuration => WhichFontSize::None, - } - } -} - pub struct AgentPanel { workspace: WeakEntity, /// Workspace id is used as a database key @@ -1178,16 +1211,17 @@ pub struct AgentPanel { thread_store: Entity, connection_store: Entity, context_server_registry: Entity, - configuration: Option>, - configuration_subscription: Option, focus_handle: FocusHandle, base_view: BaseView, last_created_entry_kind: AgentPanelEntryKind, - overlay_view: Option, draft_thread: Option>, retained_threads: HashMap>, terminals: HashMap, pending_terminal_spawn: Option, + /// Agent and captured session for terminals being restored, applied when + /// the spawned terminal registers so they survive a quit before live + /// detection re-identifies the agent. + pending_restored_agents: HashMap)>, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, _extension_subscription: Option, @@ -1502,7 +1536,7 @@ impl AgentPanel { }) } - pub(crate) fn new(workspace: &Workspace, _window: &mut Window, cx: &mut Context) -> Self { + pub(crate) fn new(workspace: &Workspace, window: &mut Window, cx: &mut Context) -> Self { let fs = workspace.app_state().fs.clone(); let user_store = workspace.app_state().user_store.clone(); let project = workspace.project(); @@ -1574,25 +1608,33 @@ impl AgentPanel { }) .detach(); + // Create the initial draft/terminal lazily, on first user focus. + // Panels are activated programmatically when added to the panel + // pane, and initializing there would surface an unrequested draft + // in the sidebar and steal the active-entry highlight. + let focus_handle = cx.focus_handle(); + cx.on_focus_in(&focus_handle, window, |this, window, cx| { + this.ensure_thread_initialized(window, cx); + }) + .detach(); + let panel = Self { workspace_id, base_view, last_created_entry_kind: AgentPanelEntryKind::Thread, - overlay_view: None, workspace, user_store, project: project.clone(), fs: fs.clone(), language_registry, connection_store, - configuration: None, - configuration_subscription: None, - focus_handle: cx.focus_handle(), + focus_handle, context_server_registry, draft_thread: None, retained_threads: HashMap::default(), terminals: HashMap::default(), pending_terminal_spawn: None, + pending_restored_agents: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -1731,8 +1773,47 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let thread = self.create_agent_thread_with_server_for_external_session( - agent, None, session_id, work_dirs, title, None, source, window, cx, + // Share-link and clipboard imports arrive with only an ACP session + // id. Mint a ThreadId and seed a metadata row so the thread routes + // through the normal thread-id resume path, and so the sidebar can + // resolve the session on subsequent opens. + let thread_id = ThreadId::new(); + if let Some(store) = ThreadMetadataStore::try_global(cx) { + let now = Utc::now(); + let worktree_paths = work_dirs + .as_ref() + .map(WorktreePaths::from_folder_paths) + .unwrap_or_default(); + store.update(cx, |store, cx| { + store.save( + ThreadMetadata { + thread_id, + session_id: Some(session_id), + agent_id: agent.id(), + title: title.clone(), + title_override: None, + updated_at: now, + created_at: Some(now), + interacted_at: None, + worktree_paths, + remote_connection: None, + archived: false, + }, + cx, + ); + }); + } + let thread = self.create_agent_thread_with_server( + agent, + None, + Some(thread_id), + work_dirs, + title, + None, + None, + source, + window, + cx, ); self.set_base_view(thread.into(), focus, window, cx); } @@ -1744,25 +1825,36 @@ impl AgentPanel { pub fn is_visible(workspace: &Entity, cx: &App) -> bool { let workspace_read = workspace.read(cx); + let Some(panel) = workspace_read.panel::(cx) else { + return false; + }; + let panel_id = Entity::entity_id(&panel); + + let visible_in_dock = workspace_read.all_docks().iter().any(|dock| { + dock.read(cx) + .visible_panel() + .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id) + }); + if visible_in_dock { + return true; + } + workspace_read - .panel::(cx) - .map(|panel| { - let panel_id = Entity::entity_id(&panel); - - workspace_read.all_docks().iter().any(|dock| { - dock.read(cx) - .visible_panel() - .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id) - }) + .panel_pane_for_kind(PaneKind::Agent, cx) + .is_some_and(|pane| { + let pane = pane.read(cx); + pane.is_visible() + && pane.active_item().is_some_and(|item| { + item.downcast::() + .is_some_and(|panel_item| panel_item.read(cx).panel_id() == panel_id) + }) }) - .unwrap_or(false) } /// Clear the active view, retaining any running thread in the background. pub fn clear_base_view(&mut self, window: &mut Window, cx: &mut Context) { let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized); self.retain_running_thread(old_view, cx); - self.clear_overlay_state(); self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); self.serialize(cx); cx.emit(AgentPanelEvent::ActiveViewChanged); @@ -1945,6 +2037,43 @@ impl AgentPanel { self.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); } + fn set_selected_agent_and_persist(&mut self, agent: Agent, cx: &mut Context) { + if self.selected_agent != agent { + self.selected_agent = agent.clone(); + self.serialize(cx); + } + + cx.background_spawn({ + let kvp = KeyValueStore::global(cx); + async move { + write_global_last_used_agent(kvp, agent).await; + } + }) + .detach(); + } + + /// Sets the panel's selected agent without opening the panel or focusing + /// it, so the agent is launched the next time the panel is opened (or + /// right away, if the panel is already showing the empty new-thread + /// draft). + pub fn select_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { + if self.project.read(cx).is_via_collab() && !agent.is_native() { + return; + } + + let showing_new_draft = matches!( + (&self.base_view, &self.draft_thread), + (BaseView::AgentThread { conversation_view }, Some(draft)) + if conversation_view.entity_id() == draft.entity_id() + ); + + if matches!(self.base_view, BaseView::AgentThread { .. }) && showing_new_draft { + self.set_selected_agent_and_persist(agent, cx); + self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + cx.notify(); + } + } + pub fn new_terminal( &mut self, workspace: Option<&Workspace>, @@ -1966,6 +2095,38 @@ impl AgentPanel { true, true, true, + None, + source, + window, + cx, + ); + } + + /// Creates a terminal thread that immediately launches the given agent + /// CLI in the project's working directory. + pub fn new_agent_terminal( + &mut self, + agent: agent_detect::AgentKind, + workspace: Option<&Workspace>, + source: AgentThreadSource, + window: &mut Window, + cx: &mut Context, + ) { + if !self.supports_terminal(cx) { + return; + } + self.set_last_created_entry_kind_from_user_action(AgentPanelEntryKind::Terminal, cx); + let working_directory = self.terminal_working_directory(workspace, cx); + self.spawn_terminal( + TerminalId::new(), + working_directory, + None, + None, + None, + true, + true, + true, + Some(vec![agent.executable().to_string()]), source, window, cx, @@ -2020,12 +2181,14 @@ impl AgentPanel { select: bool, focus: bool, run_init_command: bool, + launch_argv: Option>, source: AgentThreadSource, window: &mut Window, cx: &mut Context, ) { let terminal_working_directory = working_directory.clone(); let init_command = Self::terminal_init_command(run_init_command, cx); + let launch_command = launch_argv.as_deref().map(Self::shell_command_for_argv); let terminal_task = self.project.update(cx, |project, cx| { project.create_terminal_shell(working_directory, cx) }); @@ -2072,7 +2235,11 @@ impl AgentPanel { window, cx, ); - Self::write_terminal_init_command(&terminal_for_init_command, init_command, cx); + let startup_commands = init_command + .into_iter() + .chain(launch_command) + .collect::>(); + Self::write_terminal_init_command(&terminal_for_init_command, startup_commands, cx); })?; anyhow::Ok(()) }) @@ -2086,14 +2253,38 @@ impl AgentPanel { .filter(|command| !command.trim().is_empty()) } + /// Renders an argv as a single shell command line, quoting each argument + /// so session ids and paths reach the program as literal data. The + /// quoting is POSIX-style; captured session refs are UUID-shaped and pass + /// through unquoted, so PowerShell terminals still work in practice. + fn shell_command_for_argv(argv: &[String]) -> String { + argv.iter() + .map(|argument| { + if !argument.is_empty() + && argument + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_./=:@%+,".contains(&byte)) + { + argument.clone() + } else { + format!("'{}'", argument.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") + } + fn write_terminal_init_command( terminal: &Entity, - init_command: Option, + commands: Vec, cx: &mut Context, ) { - let Some(command) = init_command else { + if commands.is_empty() { return; - }; + } + // A single write keeps the commands ordered; the shell executes each + // line in sequence. + let command = commands.join("\x0d"); if !terminal.read(cx).is_pty() { terminal.update(cx, |terminal, _| { @@ -2182,6 +2373,8 @@ impl AgentPanel { | TerminalEvent::BreadcrumbsChanged => { this.refresh_terminal_metadata(terminal_id, cx); this.report_terminal_program(terminal_id, source, cx); + this.schedule_terminal_metadata_recheck(terminal_id, cx); + this.schedule_terminal_status_scrape(terminal_id, window, cx); } TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), TerminalEvent::CloseTerminal => { @@ -2197,6 +2390,7 @@ impl AgentPanel { let last_known_terminal_title = initial_title .map(|title| title.to_string()) .unwrap_or_default(); + let restored_agent_and_session = self.pending_restored_agents.remove(&terminal_id); let mut terminal = AgentTerminal { view: terminal_view, title_editor: None, @@ -2208,8 +2402,19 @@ impl AgentPanel { working_directory, created_at: created_at.unwrap_or_else(Utc::now), has_notification: false, + search_bar: None, notification_windows: Vec::new(), notification_subscriptions: Vec::new(), + metadata_recheck_task: None, + agent_kind: None, + restored_agent: restored_agent_and_session + .as_ref() + .map(|(agent, _)| *agent), + agent_detected_at: None, + agent_session: restored_agent_and_session.and_then(|(_, session)| session), + status_tracker: agent_detect::StatusTracker::default(), + status_scrape_task: None, + session_capture_task: None, _subscriptions: vec![view_subscription, terminal_subscription], }; if self.pending_terminal_spawn == Some(terminal_id) { @@ -2340,6 +2545,28 @@ impl AgentPanel { } } + fn schedule_terminal_metadata_recheck( + &mut self, + terminal_id: TerminalId, + cx: &mut Context, + ) { + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + terminal.metadata_recheck_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(TERMINAL_METADATA_RECHECK_DEBOUNCE) + .await; + this.update(cx, |this, cx| { + if let Some(terminal) = this.terminals.get_mut(&terminal_id) { + terminal.metadata_recheck_task = None; + } + this.refresh_terminal_metadata(terminal_id, cx); + }) + .ok(); + })); + } + fn report_terminal_program( &mut self, terminal_id: TerminalId, @@ -2351,6 +2578,231 @@ impl AgentPanel { } } + /// A one-line strip under the terminal mirroring the chat header of ACP + /// threads: agent · working directory · branch. + fn render_terminal_agent_footer(&self, cx: &Context) -> Option
{ + let terminal_id = self.active_terminal_id()?; + let terminal = self.terminals.get(&terminal_id)?; + let agent = terminal.agent_kind?; + + let mut parts = vec![agent.label().to_string()]; + let working_directory = terminal.working_directory.clone(); + if let Some(working_directory) = &working_directory { + use util::paths::PathExt as _; + parts.push(working_directory.compact().to_string_lossy().into_owned()); + } + if let Some(working_directory) = &working_directory { + let project = self.project.read(cx); + let branch = project.repositories(cx).values().find_map(|repo| { + let snapshot = repo.read(cx).snapshot(); + working_directory + .starts_with(&snapshot.work_directory_abs_path) + .then(|| { + snapshot + .branch + .as_ref() + .map(|branch| branch.name().to_string()) + }) + .flatten() + }); + if let Some(branch) = branch { + parts.push(branch); + } + } + + Some( + h_flex() + .flex_none() + .justify_center() + .py(DynamicSpacing::Base04.rems(cx)) + .px(DynamicSpacing::Base08.rems(cx)) + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(parts.join(" · ")) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ), + ) + } + + pub fn terminal_agent_status(&self, terminal_id: TerminalId) -> Option { + let terminal = self.terminals.get(&terminal_id)?; + let agent = terminal.agent_kind?; + let state = terminal.status_tracker.published_state()?; + Some(TerminalAgentStatus { agent, state }) + } + + fn schedule_terminal_status_scrape( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + if terminal.status_scrape_task.is_some() { + return; + } + terminal.status_scrape_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(TERMINAL_STATUS_SCRAPE_DEBOUNCE) + .await; + this.update_in(cx, |this, window, cx| { + if let Some(terminal) = this.terminals.get_mut(&terminal_id) { + terminal.status_scrape_task = None; + } + this.scrape_terminal_status(terminal_id, window, cx); + }) + .ok(); + })); + } + + fn scrape_terminal_status( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self.terminals.get(&terminal_id) else { + return; + }; + let terminal_state = terminal.view.read(cx).terminal().read(cx); + let agent = terminal_state + .foreground_process_command_name() + .as_deref() + .and_then(agent_detect::AgentKind::from_command_name); + let detection = agent.map(|agent| { + let screen = terminal_state + .last_n_non_empty_lines(TERMINAL_STATUS_SCRAPE_LINES) + .join("\n"); + let osc_title = terminal_state.breadcrumb_text.clone(); + agent_detect::detect( + agent, + agent_detect::DetectionInput { + screen: &screen, + osc_title: &osc_title, + }, + ) + }); + + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + let agent_changed = terminal.agent_kind != agent; + if agent_changed { + let previous_agent = terminal.agent_kind.or(terminal.restored_agent); + terminal.agent_kind = agent; + // Live detection has spoken; the restored hint is obsolete. + terminal.restored_agent = None; + if agent != previous_agent { + // A session captured for one agent must never be replayed + // through another agent's resume command. + terminal.agent_session = None; + } + terminal.status_tracker.reset(); + terminal.agent_detected_at = agent + .is_some() + .then(std::time::SystemTime::now) + .map(|now| now - TERMINAL_SESSION_CAPTURE_SLACK); + } + + let mut transition = None; + if let Some(detection) = detection { + let previous = terminal.status_tracker.published_state(); + if let Some(published) = terminal + .status_tracker + .update(detection, std::time::Instant::now()) + { + transition = Some((previous, published)); + } + } + let reschedule = terminal.status_tracker.is_holding_idle(); + + if agent_changed { + // The persisted agent label is what relaunches the CLI on + // restore; a status transition in the same scrape must not + // swallow this write. + self.persist_terminal_metadata(terminal_id, cx); + } + if let Some((previous, published)) = transition { + let needs_attention = published == agent_detect::AgentState::Blocked + || (published == agent_detect::AgentState::Idle + && previous == Some(agent_detect::AgentState::Working)); + if needs_attention { + self.mark_terminal_notification(terminal_id, window, cx); + } + self.schedule_agent_session_capture(terminal_id, cx); + } + if agent_changed || transition.is_some() { + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + + if reschedule { + self.schedule_terminal_status_scrape(terminal_id, window, cx); + } + } + + /// Looks up on disk which session the agent CLI is writing, so the + /// conversation can be resumed when the terminal thread is restored + /// after a restart. Runs after status transitions, when the agent has + /// just flushed output. + fn schedule_agent_session_capture(&mut self, terminal_id: TerminalId, cx: &mut Context) { + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + if terminal.session_capture_task.is_some() { + return; + } + let Some(agent) = terminal.agent_kind else { + return; + }; + let Some(since) = terminal.agent_detected_at else { + return; + }; + let Some(cwd) = terminal.working_directory.clone() else { + return; + }; + if self.project.read(cx).remote_connection_options(cx).is_some() { + // Session files live on the remote host; local discovery would + // find another project's sessions. + return; + } + let home_dir = paths::home_dir().clone(); + let current_session = terminal.agent_session.clone(); + + terminal.session_capture_task = Some(cx.spawn(async move |this, cx| { + let session = cx + .background_spawn(async move { + agent_detect::session_discovery::find_session( + agent, + &home_dir, + &cwd, + since, + current_session.as_deref(), + ) + }) + .await; + this.update(cx, |this, cx| { + let Some(terminal) = this.terminals.get_mut(&terminal_id) else { + return; + }; + terminal.session_capture_task = None; + if let Some(session) = session + && terminal.agent_kind == Some(agent) + && terminal.agent_session.as_deref() != Some(session.as_str()) + { + terminal.agent_session = Some(session); + this.persist_terminal_metadata(terminal_id, cx); + } + }) + .ok(); + })); + } + fn persist_all_terminal_metadata(&self, cx: &mut Context) { let terminal_ids = self.terminals.keys().copied().collect::>(); for terminal_id in terminal_ids { @@ -2385,6 +2837,11 @@ impl AgentPanel { worktree_paths: project.worktree_paths(cx), remote_connection: project.remote_connection_options(cx), working_directory: terminal.working_directory.clone(), + agent: terminal + .agent_kind + .or(terminal.restored_agent) + .map(|agent| agent.label().to_string()), + agent_session: terminal.agent_session.clone(), }) } @@ -2409,6 +2866,15 @@ impl AgentPanel { self.pending_terminal_spawn = Some(metadata.terminal_id); let working_directory = self.terminal_restore_working_directory(&metadata, workspace, cx); let initial_title = Self::terminal_restore_initial_title(&metadata); + let launch_argv = Self::terminal_restore_launch_argv(&metadata); + if let Some(agent) = metadata + .agent + .as_deref() + .and_then(agent_detect::AgentKind::from_command_name) + { + self.pending_restored_agents + .insert(metadata.terminal_id, (agent, metadata.agent_session.clone())); + } self.spawn_terminal( metadata.terminal_id, working_directory, @@ -2418,12 +2884,25 @@ impl AgentPanel { true, focus, true, + launch_argv, source, window, cx, ); } + /// The command to relaunch a restored terminal thread's agent with its + /// prior conversation: `claude --resume ` / `codex resume + /// ` when a session was captured, otherwise the agent's + /// relaunch fallback. + fn terminal_restore_launch_argv(metadata: &TerminalThreadMetadata) -> Option> { + let agent = agent_detect::AgentKind::from_command_name(metadata.agent.as_deref()?)?; + Some(match metadata.agent_session.as_deref() { + Some(session) => agent.resume_argv(session), + None => agent.relaunch_argv(), + }) + } + fn restore_terminal_for_panel_load( &mut self, metadata: TerminalThreadMetadata, @@ -2645,11 +3124,13 @@ impl AgentPanel { let settings = AgentSettings::get_global(cx); match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { + window.request_attention(); if let Some(primary) = cx.primary_display() { self.pop_up_terminal_notification(terminal_id, &title, primary, window, cx); } } NotifyWhenAgentWaiting::AllScreens => { + window.request_attention(); for screen in cx.displays() { self.pop_up_terminal_notification(terminal_id, &title, screen, window, cx); } @@ -2938,15 +3419,7 @@ impl AgentPanel { let draft = self.ensure_draft(source, window, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.entity_id() == draft.entity_id() { - // If we're already viewing the draft as the base view but an - // overlay (e.g. Settings) is covering it, clear the overlay - // so the user actually sees the draft they asked for. - // Otherwise pressing "New Thread" from the Settings panel is - // a silent no-op because the early return below would leave - // the overlay on top of the draft. - if self.overlay_view.is_some() { - self.clear_overlay(focus, window, cx); - } else if focus { + if focus { self.focus_handle(cx).focus(window, cx); } return; @@ -3219,18 +3692,7 @@ impl AgentPanel { cx, ); if let Some(original) = saved_selected_agent { - if self.selected_agent != original { - self.selected_agent = original.clone(); - self.serialize(cx); - // Restore the last-used-agent in persistent storage as well. - cx.background_spawn({ - let kvp = KeyValueStore::global(cx); - async move { - write_global_last_used_agent(kvp, original).await; - } - }) - .detach(); - } + self.set_selected_agent_and_persist(original, cx); } let thread_id = thread.conversation_view.read(cx).thread_id; self.retained_threads @@ -3312,7 +3774,6 @@ impl AgentPanel { } if self.active_thread_id(cx) == Some(id) { - self.clear_overlay_state(); if activate_draft_after_remove { self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); } else { @@ -3355,6 +3816,9 @@ impl AgentPanel { has_notification: terminal.has_notification, custom_title: terminal.custom_title(cx), working_directory: terminal.working_directory.clone(), + agent_status: terminal.agent_kind.zip(terminal.status_tracker.published_state()).map( + |(agent, state)| TerminalAgentStatus { agent, state }, + ), }) .collect() } @@ -3557,13 +4021,6 @@ impl AgentPanel { }) } - pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { - if self.overlay_view.is_some() { - self.clear_overlay(true, window, cx); - cx.notify(); - } - } - pub fn toggle_options_menu( &mut self, _: &ToggleOptionsMenu, @@ -3676,62 +4133,18 @@ impl AgentPanel { if !self.focus_handle(cx).contains_focused(window, cx) { cx.focus_self(window); } - cx.emit(PanelEvent::ZoomIn); - } - } - - pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { - // When the agent settings have been moved into the settings UI, the - // panel no longer shows its own configuration overlay. Instead, route to - // the settings UI at the LLM providers page (where the model selector's - // "Configure" button expects to land). - if cx.has_flag::() { - window.dispatch_action( - Box::new(zed_actions::OpenSettingsAt { - path: "llm_providers".to_string(), - target: None, - }), - cx, - ); - return; - } - - if matches!(self.overlay_view, Some(OverlayView::Configuration)) { - self.clear_overlay(true, window, cx); - return; - } - - let agent_server_store = self.project.read(cx).agent_server_store().clone(); - let context_server_store = self.project.read(cx).context_server_store(); - let fs = self.fs.clone(); - - self.configuration = Some(cx.new(|cx| { - AgentConfiguration::new( - fs, - agent_server_store, - self.connection_store.clone(), - context_server_store, - self.context_server_registry.clone(), - self.language_registry.clone(), - self.workspace.clone(), - window, - cx, - ) - })); - - if let Some(configuration) = self.configuration.as_ref() { - self.configuration_subscription = Some(cx.subscribe_in( - configuration, - window, - Self::handle_agent_configuration_event, - )); + cx.emit(PanelEvent::ZoomIn); } + } - self.set_overlay(OverlayView::Configuration, true, window, cx); - - if let Some(configuration) = self.configuration.as_ref() { - configuration.focus_handle(cx).focus(window, cx); - } + pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsPage { + page: "AI".to_string(), + target: None, + }), + cx, + ); } pub(crate) fn open_active_thread_as_markdown( @@ -4014,53 +4427,6 @@ impl AgentPanel { .detach_and_log_err(cx); } - fn handle_agent_configuration_event( - &mut self, - _entity: &Entity, - event: &AssistantConfigurationEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - AssistantConfigurationEvent::NewThread(provider) => { - if LanguageModelRegistry::read_global(cx) - .default_model() - .is_none_or(|model| model.provider.id() != provider.id()) - && let Some(model) = provider.default_model(cx) - { - update_settings_file(self.fs.clone(), cx, move |settings, _| { - let provider = model.provider_id().0.to_string(); - let enable_thinking = model.supports_thinking(); - let effort = model - .default_effort_level() - .map(|effort| effort.value.to_string()); - let model = model.id().0.to_string(); - settings - .agent - .get_or_insert_default() - .set_model(LanguageModelSelection { - provider: LanguageModelProviderSetting(provider), - model, - enable_thinking, - effort, - speed: None, - }) - }); - } - - self.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); - if let Some((thread, model)) = self - .active_native_agent_thread(cx) - .zip(provider.default_model(cx)) - { - thread.update(cx, |thread, cx| { - thread.set_model(model, cx); - }); - } - } - } - } - pub fn workspace_id(&self) -> Option { self.workspace_id } @@ -4090,6 +4456,37 @@ impl AgentPanel { } } + fn toggle_terminal_thread_search( + &mut self, + _: &crate::ToggleSearch, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get_mut(&terminal_id)) + else { + cx.propagate(); + return; + }; + + let terminal_view = terminal.view.clone(); + let search_bar = terminal + .search_bar + .get_or_insert_with(|| cx.new(|cx| BufferSearchBar::new(None, window, cx))) + .clone(); + let deployed = search_bar.update(cx, |search_bar, cx| { + let terminal_item: &dyn ItemHandle = &terminal_view; + search_bar.set_active_pane_item(Some(terminal_item), window, cx); + search_bar.deploy(&DeployBufferSearch::find(), None, window, cx) + }); + if deployed { + cx.stop_propagation(); + } else { + cx.propagate(); + } + } + pub fn conversation_view_for_id( &self, thread_id: &ThreadId, @@ -4277,8 +4674,6 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.clear_overlay_state(); - let old_view = std::mem::replace(&mut self.base_view, new_view); self.retain_running_thread(old_view, cx); @@ -4299,35 +4694,6 @@ impl AgentPanel { cx.emit(AgentPanelEvent::ActiveViewChanged); } - fn set_overlay( - &mut self, - overlay: OverlayView, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.overlay_view = Some(overlay); - if focus { - self.focus_handle(cx).focus(window, cx); - } - cx.emit(AgentPanelEvent::ActiveViewChanged); - } - - fn clear_overlay(&mut self, focus: bool, window: &mut Window, cx: &mut Context) { - self.clear_overlay_state(); - - if focus { - self.focus_handle(cx).focus(window, cx); - } - cx.emit(AgentPanelEvent::ActiveViewChanged); - } - - fn clear_overlay_state(&mut self) { - self.overlay_view = None; - self.configuration_subscription = None; - self.configuration = None; - } - fn refresh_base_view_subscriptions(&mut self, window: &mut Window, cx: &mut Context) { self._base_view_observation = match &self.base_view { BaseView::AgentThread { conversation_view } => { @@ -4380,14 +4746,6 @@ impl AgentPanel { } fn visible_surface(&self) -> VisibleSurface<'_> { - if let Some(overlay_view) = &self.overlay_view { - return match overlay_view { - OverlayView::Configuration => { - VisibleSurface::Configuration(self.configuration.as_ref()) - } - }; - } - match &self.base_view { BaseView::Uninitialized => VisibleSurface::Uninitialized, BaseView::AgentThread { conversation_view } => { @@ -4401,15 +4759,8 @@ impl AgentPanel { } } - fn is_overlay_open(&self) -> bool { - self.overlay_view.is_some() - } - fn visible_font_size(&self) -> WhichFontSize { - self.overlay_view.as_ref().map_or_else( - || self.base_view.which_font_size_used(), - OverlayView::which_font_size_used, - ) + self.base_view.which_font_size_used() } fn subscribe_to_active_thread_view( @@ -4493,7 +4844,6 @@ impl AgentPanel { // Check if the active view already holds this thread. if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.read(cx).thread_id == thread_id { - self.clear_overlay_state(); cx.emit(AgentPanelEvent::ActiveViewChanged); return; } @@ -4586,41 +4936,6 @@ impl AgentPanel { ) } - /// Legacy entry that resumes a thread by raw ACP session id when no - /// local [`ThreadMetadata`] row exists yet (share-link imports and - /// clipboard imports). - /// - /// TODO(legacy-session-id): migrate remaining callers (share-link - /// handler, clipboard import) to mint a [`ThreadId`] + seed metadata - /// so they can route through [`create_agent_thread_with_server`] and - /// this entry can be deleted. - fn create_agent_thread_with_server_for_external_session( - &mut self, - agent: Agent, - server_override: Option>, - resume_session_id: acp::SessionId, - work_dirs: Option, - title: Option, - initial_content: Option, - source: AgentThreadSource, - window: &mut Window, - cx: &mut Context, - ) -> AgentThread { - self.create_agent_thread_inner( - agent, - server_override, - None, - Some(resume_session_id), - work_dirs, - title, - initial_content, - None, - source, - window, - cx, - ) - } - fn create_agent_thread_inner( &mut self, agent: Agent, @@ -4639,19 +4954,7 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); - if self.selected_agent != agent { - self.selected_agent = agent.clone(); - self.serialize(cx); - } - - cx.background_spawn({ - let kvp = KeyValueStore::global(cx); - let agent = agent.clone(); - async move { - write_global_last_used_agent(kvp, agent).await; - } - }) - .detach(); + self.set_selected_agent_and_persist(agent.clone(), cx); let server = server_override .unwrap_or_else(|| agent.server(self.fs.clone(), self.thread_store.clone())); @@ -5046,13 +5349,6 @@ impl Focusable for AgentPanel { VisibleSurface::Uninitialized => self.focus_handle.clone(), VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx), - VisibleSurface::Configuration(configuration) => { - if let Some(configuration) = configuration { - configuration.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } } } } @@ -5137,7 +5433,7 @@ impl Panel for AgentPanel { fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { self.is_active = active; - if active { + if active && self.focus_handle(cx).contains_focused(window, cx) { self.ensure_thread_initialized(window, cx); } } @@ -5251,6 +5547,7 @@ impl AgentPanel { true, false, true, + None, source, window, cx, @@ -5288,10 +5585,7 @@ impl AgentPanel { } fn destination_has_meaningful_state(&self, cx: &App) -> bool { - if self.overlay_view.is_some() - || !self.retained_threads.is_empty() - || !self.terminals.is_empty() - { + if !self.retained_threads.is_empty() || !self.terminals.is_empty() { return true; } @@ -5456,9 +5750,9 @@ impl AgentPanel { let is_generating_title = native_thread .as_ref() .is_some_and(|thread| thread.read(cx).is_generating_title()); - let title_generation_failed = native_thread + let title_generation_error = native_thread .as_ref() - .is_some_and(|thread| thread.read(cx).has_failed_title_generation()); + .and_then(|thread| thread.read(cx).title_generation_error()); if let Some(title_editor) = server_view_ref .root_thread_view() @@ -5497,7 +5791,7 @@ impl AgentPanel { }) .child(title_editor); - if title_generation_failed { + if let Some(title_generation_error) = title_generation_error { h_flex() .w_full() .gap_1() @@ -5506,7 +5800,14 @@ impl AgentPanel { IconButton::new("retry-thread-title", IconName::XCircle) .icon_color(Color::Error) .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Title generation failed. Retry")) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Title generation failed. Click to retry.", + None, + title_generation_error.clone(), + cx, + ) + }) .on_click({ let conversation_view = conversation_view.clone(); let workspace = self.workspace.clone(); @@ -5572,9 +5873,7 @@ impl AgentPanel { Label::new("Terminal").into_any_element() } } - VisibleSurface::Configuration(_) => { - Label::new("Settings").truncate().into_any_element() - } + VisibleSurface::Uninitialized => Label::new("Agent").truncate().into_any_element(), }; @@ -5683,6 +5982,10 @@ impl AgentPanel { .is_some_and(|thread| !thread.read(cx).is_generating_title()) }); + let has_thread_messages = conversation_view.as_ref().is_some_and(|conversation_view| { + conversation_view.read(cx).has_user_submitted_prompt(cx) + }); + let has_auth_methods = match &self.base_view { BaseView::AgentThread { conversation_view } => { conversation_view.read(cx).has_auth_methods() @@ -5718,15 +6021,15 @@ impl AgentPanel { .with_handle(self.agent_panel_menu_handle.clone()) .menu({ move |window, cx| { - Some(ContextMenu::build(window, cx, |mut menu, _window, _| { + Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { menu = menu.context(menu_action_context.clone()); - if can_regenerate_thread_title { + if has_thread_messages { menu = menu.header("Current Thread"); if let Some(conversation_view) = conversation_view.as_ref() { - menu = menu - .entry("Regenerate Thread Title", None, { + if can_regenerate_thread_title { + menu = menu.entry("Regenerate Thread Title", None, { let conversation_view = conversation_view.clone(); let workspace = workspace.clone(); move |_, cx| { @@ -5736,16 +6039,42 @@ impl AgentPanel { cx, ); } - }) - .separator(); + }); + } + + let root_thread_view = + conversation_view.read(cx).root_thread_view(); + if let Some(thread_view) = root_thread_view { + let workspace = workspace.clone(); + menu = menu.entry("Open Thread as Markdown", None, { + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view + .open_thread_as_markdown( + workspace, window, cx, + ) + .detach_and_log_err(cx); + }); + } + } + }); + } + + menu = menu.separator(); } } if !showing_terminal { menu = menu .header("MCP Servers") - .action("Add Custom Server…", Box::new(AddContextServer::local())) - .action("Add Remote Server…", Box::new(AddContextServer::remote())) + .action( + "Add Server…", + Box::new(zed_actions::OpenSettingsAt { + path: "context_servers".to_string(), + target: None, + }), + ) .action( "Install New Servers…", Box::new(zed_actions::Extensions { @@ -5810,11 +6139,11 @@ impl AgentPanel { }, ); } - - menu = menu.separator(); } - menu = menu.action("Profiles", Box::new(ManageProfiles::default())); + menu = menu + .separator() + .action("Profiles", Box::new(ManageProfiles::default())); } menu = menu @@ -5838,21 +6167,6 @@ impl AgentPanel { }) } - fn render_toolbar_back_button(&self, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - IconButton::new("go-back", IconName::ArrowLeft) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.go_back(&workspace::GoBack, window, cx); - })) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx) - } - }) - } - fn render_no_project_state(&self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -5945,7 +6259,39 @@ impl AgentPanel { }), ) .when(supports_terminal, |menu| { - menu.item( + let agent_terminal_item = |menu: ContextMenu, + label: &'static str, + icon: IconName, + agent: agent_detect::AgentKind| { + menu.item( + ContextMenuEntry::new(label) + .icon(icon) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_terminal( + agent, + Some(workspace), + AgentThreadSource::AgentPanel, + window, + cx, + ); + }); + } + }); + } + } + }), + ) + }; + let menu = menu.item( ContextMenuEntry::new("Terminal") .when(showing_terminal, |this| this.action(Box::new(NewThread))) .when(!showing_terminal, |this| { @@ -5974,6 +6320,18 @@ impl AgentPanel { } } }), + ); + let menu = agent_terminal_item( + menu, + "Claude Code Terminal", + IconName::AiClaude, + agent_detect::AgentKind::ClaudeCode, + ); + agent_terminal_item( + menu, + "Codex Terminal", + IconName::AiOpenAi, + agent_detect::AgentKind::Codex, ) }) .map(|mut menu| { @@ -6133,15 +6491,12 @@ impl AgentPanel { }; enum ToolbarMode { - Overlay, Terminal, EmptyThread, ActiveThread, } - let mode = if self.is_overlay_open() { - ToolbarMode::Overlay - } else if matches!(self.base_view, BaseView::Terminal { .. }) { + let mode = if matches!(self.base_view, BaseView::Terminal { .. }) { ToolbarMode::Terminal } else if self.active_thread_has_messages(cx) { ToolbarMode::ActiveThread @@ -6188,6 +6543,44 @@ impl AgentPanel { .into_any_element() }); + // "Claude Code 1.2.3 · Claude Fable 5 · ~/dev/paykit" meta line for the + // active thread; the tooltip carries the full working directory path. + let thread_meta = matches!(mode, ToolbarMode::ActiveThread) + .then(|| self.active_conversation_view()) + .flatten() + .map(|conversation_view| { + let view = conversation_view.read(cx); + let harness = match view.agent_server_version() { + Some(version) => format!("{} {}", selected_agent_label, version), + None => selected_agent_label.to_string(), + }; + let mut parts = vec![harness]; + if let Some(model) = view.active_model_name(cx) { + parts.push(model.to_string()); + } + let mut tooltip_parts = parts.clone(); + if let Some(cwd) = view.primary_work_dir_display(cx) { + parts.push(cwd.to_string()); + } + if let Some(cwd) = view.primary_work_dir(cx) { + tooltip_parts.push(cwd.display().to_string()); + } + let tooltip_text = SharedString::from(tooltip_parts.join(" · ")); + h_flex() + .id("agent-thread-meta") + .min_w_0() + .flex_shrink(1.) + .overflow_hidden() + .tooltip(Tooltip::text(tooltip_text)) + .child( + Label::new(parts.join(" · ")) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ) + .into_any_element() + }); + let toolbar_content = { let new_thread_menu = PopoverMenu::new("new_thread_menu") .trigger_with_tooltip( @@ -6225,15 +6618,12 @@ impl AgentPanel { .overflow_hidden() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) - .child(if matches!(mode, ToolbarMode::Overlay) { - self.render_toolbar_back_button(cx).into_any_element() - } else { - selected_agent.into_any_element() - }) + .child(selected_agent.into_any_element()) .child(match empty_thread_title { Some(title) => title, None => self.render_title_view(window, cx), - }), + }) + .children(thread_meta), ) .child( h_flex() @@ -6586,12 +6976,12 @@ impl Render for AgentPanel { })) .on_action(cx.listener(Self::open_active_thread_as_markdown)) .on_action(cx.listener(Self::manage_skills)) - .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_options_menu)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) + .on_action(cx.listener(Self::toggle_terminal_thread_search)) .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { if let Some(conversation_view) = this.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { @@ -6616,11 +7006,34 @@ impl Render for AgentPanel { VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) .child(self.render_drag_target(cx)), - VisibleSurface::Terminal(terminal_view) => parent - .child(terminal_view.clone()) - .child(self.render_drag_target(cx)), - VisibleSurface::Configuration(configuration) => { - parent.children(configuration.cloned()) + VisibleSurface::Terminal(terminal_view) => { + let search_bar = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get(&terminal_id)) + .and_then(|terminal| terminal.search_bar.clone()); + let terminal_content = v_flex() + .size_full() + .when_some(search_bar, |this, search_bar| { + this.when(!search_bar.read(cx).is_dismissed(), |this| { + this.child( + v_flex() + .group("toolbar") + .relative() + .py(DynamicSpacing::Base06.rems(cx)) + .px(DynamicSpacing::Base08.rems(cx)) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().toolbar_background) + .child(search_bar), + ) + }) + }) + .child(terminal_view.clone()) + .children(self.render_terminal_agent_footer(cx)); + + parent + .child(terminal_content) + .child(self.render_drag_target(cx)) } }) .children(self.render_trial_end_upsell(window, cx)); @@ -6882,7 +7295,11 @@ impl AgentPanel { window, cx, ); - Self::write_terminal_init_command(&terminal_for_init_command, init_command, cx); + Self::write_terminal_init_command( + &terminal_for_init_command, + init_command.into_iter().collect(), + cx, + ); Ok(()) } @@ -7414,6 +7831,8 @@ mod tests { worktree_paths: project.read_with(cx, |project, cx| project.worktree_paths(cx)), remote_connection: None, working_directory: None, + agent: None, + agent_session: None, }; assert_eq!(metadata.working_directory, None); @@ -7459,6 +7878,9 @@ mod tests { panel.update_in(&mut cx, |panel, window, cx| { panel.last_created_entry_kind = AgentPanelEntryKind::Terminal; + // Initialization requires user focus; programmatic activation + // alone must not create the initial entry. + panel.focus_handle(cx).focus(window, cx); panel.set_active(true, window, cx); panel.set_active(true, window, cx); }); @@ -7498,6 +7920,8 @@ mod tests { )])), remote_connection: None, working_directory: None, + agent: None, + agent_session: None, }; let terminal_id = metadata.terminal_id; panel @@ -7589,6 +8013,7 @@ mod tests { true, true, true, + None, AgentThreadSource::AgentPanel, window, cx, @@ -7672,6 +8097,8 @@ mod tests { )])), remote_connection: None, working_directory: None, + agent: None, + agent_session: None, }; panel .update_in(&mut cx, |panel, window, cx| { @@ -7759,6 +8186,9 @@ mod tests { workspace.add_panel(loaded.clone(), window, cx); }); loaded.update_in(cx, |panel, window, cx| { + // Initialization requires user focus; programmatic activation + // alone must not create the initial entry. + panel.focus_handle(cx).focus(window, cx); panel.set_active(true, window, cx); }); for _ in 0..8 { @@ -7984,6 +8414,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project", json!({ "file.txt": "" })).await; let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; @@ -8074,6 +8505,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project_a", json!({ "file.txt": "" })) .await; fs.insert_tree("/project_b", json!({ "file.txt": "" })) @@ -8460,6 +8892,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({ "file.txt": "" })).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = @@ -8651,6 +9084,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project", json!({ "file.txt": "" })).await; let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; @@ -8880,6 +9314,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project", json!({ "file.txt": "" })).await; let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; @@ -9055,6 +9490,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree( "/project", json!({ "file.rs": "line one\nline two\nline three\n" }), @@ -9262,7 +9698,7 @@ mod tests { let mut text = String::new(); for path in paths { text.push(' '); - text.push_str(&format!("{path:?}")); + text.push_str(&shlex::try_quote(path.to_str().unwrap()).unwrap()); } text.push(' '); text @@ -9482,43 +9918,135 @@ mod tests { fs.insert_tree("/project", json!({ "file.txt": "" })).await; let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + open_thread_with_connection(&panel, StubAgentConnection::new(), &mut cx); + workspace.update_in(&mut cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.toggle_options_menu(&ToggleOptionsMenu, window, cx); + }); + cx.run_until_parked(); + + assert!( + cx.debug_bounds("MENU_ITEM-Skills").is_some(), + "Skills menu item should be visible" + ); + assert!( + cx.debug_bounds("KEY_BINDING-l").is_some(), + "Skills menu item should show the ManageSkills shortcut" + ); + } + + #[gpui::test] + async fn test_terminal_close_event_closes_without_sidebar(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + panel.update(&mut cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert!(!panel.has_terminal(terminal_id)); + }); + cx.update(|_, cx| { + assert!( + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none(), + "terminal metadata should be deleted by the fallback close" + ); + }); + } + + #[gpui::test] + async fn test_terminal_custom_title_rename_persists_to_metadata_store(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) }) - .unwrap(); - let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); + .expect("test terminal should be inserted"); + cx.run_until_parked(); - let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); - workspace.add_panel(panel.clone(), window, cx); + let terminal_view = panel.read_with(&cx, |panel, _cx| { panel + .terminals + .get(&terminal_id) + .expect("terminal should exist") + .view + .clone() }); - open_thread_with_connection(&panel, StubAgentConnection::new(), &mut cx); - workspace.update_in(&mut cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); + terminal_view.update(&mut cx, |terminal_view, cx| { + terminal_view.set_custom_title(Some("Fix bug".to_string()), cx); }); cx.run_until_parked(); - panel.update_in(&mut cx, |panel, window, cx| { - panel.toggle_options_menu(&ToggleOptionsMenu, window, cx); + cx.update(|_, cx| { + let store = TerminalThreadMetadataStore::global(cx); + let entry = store + .read(cx) + .entry(terminal_id) + .cloned() + .expect("terminal metadata should exist"); + assert_eq!(entry.custom_title.as_deref(), Some("Fix bug")); }); - cx.run_until_parked(); - assert!( - cx.debug_bounds("MENU_ITEM-Skills").is_some(), - "Skills menu item should be visible" - ); - assert!( - cx.debug_bounds("KEY_BINDING-l").is_some(), - "Skills menu item should show the ManageSkills shortcut" - ); + // Simulate a restart: a fresh store must read the rename back from + // the database. + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + let reload = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .reload_task() + }); + reload.await; + cx.update(|_, cx| { + let store = TerminalThreadMetadataStore::global(cx); + let entry = store + .read(cx) + .entry(terminal_id) + .cloned() + .expect("rename should survive a reload from the database"); + assert_eq!(entry.custom_title.as_deref(), Some("Fix bug")); + }); } #[gpui::test] - async fn test_terminal_close_event_closes_without_sidebar(cx: &mut TestAppContext) { + async fn test_close_terminal_removes_metadata_row_from_database(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; cx.update(|_, cx| { TerminalThreadMetadataStore::init_global(cx); @@ -9531,79 +10059,162 @@ mod tests { .expect("test terminal should be inserted"); cx.run_until_parked(); - panel.update(&mut cx, |panel, cx| { - panel.emit_test_terminal_close(terminal_id, cx); + panel.update_in(&mut cx, |panel, window, cx| { + panel.close_terminal_without_activating_draft(terminal_id, window, cx); }); cx.run_until_parked(); panel.read_with(&cx, |panel, _cx| { assert!(!panel.has_terminal(terminal_id)); }); + + // Simulate a restart: a fresh store must not find the deleted row. + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + let reload = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .reload_task() + }); + reload.await; cx.update(|_, cx| { assert!( TerminalThreadMetadataStore::global(cx) .read(cx) .entry(terminal_id) .is_none(), - "terminal metadata should be deleted by the fallback close" + "closing a terminal must delete its database row" ); }); } #[gpui::test] - async fn test_new_thread_dismisses_settings_overlay(cx: &mut TestAppContext) { + async fn test_restores_multiple_terminals_for_same_worktree_in_created_at_order( + cx: &mut TestAppContext, + ) { let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); - // Put the panel on its ephemeral new-draft view so the base view - // already contains the draft that `NewThread` would activate. - panel.update_in(&mut cx, |panel, window, cx| { - panel.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); + let folder_paths = PathList::new(&[Path::new("/project")]); + let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths); + let base = Utc::now(); + let older = TerminalThreadMetadata { + terminal_id: TerminalId::new(), + title: "older".into(), + custom_title: None, + created_at: base - chrono::Duration::seconds(60), + worktree_paths: worktree_paths.clone(), + remote_connection: None, + working_directory: None, + agent: None, + agent_session: None, + }; + let newer = TerminalThreadMetadata { + terminal_id: TerminalId::new(), + title: "newer".into(), + custom_title: None, + created_at: base, + worktree_paths, + remote_connection: None, + working_directory: None, + agent: None, + agent_session: None, + }; + assert_ne!(older.terminal_id, newer.terminal_id); + + // Save the newest first: restore order must come from `created_at`, + // not from insertion order. + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(newer.clone(), cx); + store.save(older.clone(), cx); + }); }); cx.run_until_parked(); - panel.read_with(&cx, |panel, cx| { - assert!( - panel.active_view_is_new_draft(cx), - "precondition: base view should be the ephemeral draft" - ); - assert!(!panel.is_overlay_open()); + let ordered = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&folder_paths, None) + .cloned() + .collect::>() }); + assert_eq!( + ordered + .iter() + .map(|metadata| metadata.terminal_id) + .collect::>(), + vec![older.terminal_id, newer.terminal_id] + ); - // Simulate the Settings overlay being open on top of the draft. - // We don't go through `open_configuration` here because it would - // build provider configuration views, which call into - // `LanguageModelProvider::configuration_view` — unimplemented for - // the fake provider used in tests. The bug being exercised lives - // entirely in the overlay/base-view bookkeeping, so toggling the - // overlay flag directly is sufficient. panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); + for metadata in ordered { + panel.restore_terminal_for_panel_load( + metadata, + false, + AgentThreadSource::AgentPanel, + None, + window, + cx, + ); + } }); cx.run_until_parked(); - panel.read_with(&cx, |panel, _cx| { - assert!( - panel.is_overlay_open(), - "precondition: Settings overlay should be open" - ); + panel.read_with(&cx, |panel, cx| { + assert!(panel.has_terminal(older.terminal_id)); + assert!(panel.has_terminal(newer.terminal_id)); + assert_eq!(panel.terminals(cx).len(), 2); }); - // Dispatching `NewThread` while Settings is open must dismiss the - // overlay so the user actually sees the new thread. Previously - // this was a silent no-op: `activate_draft` early-returned without - // clearing the overlay because the base view already held the - // draft. - panel.update_in(&mut cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); + // Restoring re-persists the terminals; their created_at (and thus + // their order) must be preserved. + cx.run_until_parked(); + cx.update(|_, cx| { + let store = TerminalThreadMetadataStore::global(cx); + let restored_older = store + .read(cx) + .entry(older.terminal_id) + .cloned() + .expect("older terminal should still be in the store"); + assert_eq!(restored_older.created_at, older.created_at); + let restored_newer = store + .read(cx) + .entry(newer.terminal_id) + .cloned() + .expect("newer terminal should still be in the store"); + assert_eq!(restored_newer.created_at, newer.created_at); + }); + } + + #[gpui::test] + async fn test_terminal_surface_is_panel_focus_target(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); cx.run_until_parked(); + // Pane navigation (workspace::ActivatePaneLeft/Right and the sidebar + // focus cycling) focuses the panel through `Focusable`, so the + // panel's focus handle must target the visible terminal surface. panel.read_with(&cx, |panel, cx| { - assert!( - !panel.is_overlay_open(), - "Settings overlay should be dismissed when invoking NewThread" - ); - assert!(panel.active_view_is_new_draft(cx)); + let terminal_view = panel + .terminals + .get(&terminal_id) + .expect("terminal should exist") + .view + .clone(); + assert_eq!(panel.focus_handle(cx), terminal_view.focus_handle(cx)); }); } @@ -9725,6 +10336,8 @@ mod tests { )])), remote_connection: None, working_directory: None, + agent: None, + agent_session: None, }; panel.update_in(&mut cx, |panel, window, cx| { @@ -9776,6 +10389,8 @@ mod tests { )])), remote_connection: None, working_directory: None, + agent: None, + agent_session: None, }; panel.update_in(&mut cx, |panel, window, cx| { @@ -10257,12 +10872,26 @@ mod tests { .workspace() .clone() }); - workspace.update_in(&mut cx, |workspace, window, cx| { - workspace.focus_handle(cx).focus(window, cx); + // Focusing the workspace's own focus handle would land on the active + // pane, which still hosts the agent panel item; move focus to the + // tabbed center pane instead so the panel loses focus. + let center_pane = workspace.read_with(&cx, |workspace, cx| { + workspace + .panel_pane_for_kind(PaneKind::Tabs, cx) + .expect("workspace should have a tabbed center pane") + }); + center_pane.update_in(&mut cx, |pane, window, cx| { + pane.focus_handle(cx).focus(window, cx); }); + cx.run_until_parked(); cx.update(|window, cx| { assert!(window.is_window_active()); - assert!(workspace.read(cx).focus_handle(cx).is_focused(window)); + assert!( + center_pane + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + ); assert!(!panel.read(cx).focus_handle(cx).contains_focused(window, cx)); }); @@ -10286,62 +10915,6 @@ mod tests { ); } - #[gpui::test] - async fn test_terminal_bell_notifies_when_configuration_overlay_covers_terminal( - cx: &mut TestAppContext, - ) { - let (panel, mut cx) = setup_visible_panel(cx).await; - let terminal_id = panel - .update_in(&mut cx, |panel, window, cx| { - panel.insert_test_terminal("Claude", true, window, cx) - }) - .expect("test terminal should be inserted"); - cx.run_until_parked(); - - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - panel.update(&mut cx, |panel, cx| { - panel.emit_test_terminal_bell(terminal_id, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - let terminal = panel - .terminals(cx) - .into_iter() - .find(|terminal| terminal.id == terminal_id) - .expect("terminal should remain in the panel"); - assert!(terminal.has_notification); - }); - cx.windows() - .iter() - .find_map(|window| window.downcast::()) - .expect("covered terminal bell should show a notification"); - } - - #[gpui::test] - async fn test_thread_notification_shows_when_configuration_overlay_covers_thread( - cx: &mut TestAppContext, - ) { - let (panel, mut cx) = setup_visible_panel(cx).await; - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Default response".into()), - )]); - open_thread_with_connection(&panel, connection, &mut cx); - - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - send_message(&panel, &mut cx); - - cx.windows() - .iter() - .find_map(|window| window.downcast::()) - .expect("covered thread should show a notification"); - } - #[gpui::test] async fn test_terminal_bell_marks_without_popup_when_sidebar_open(cx: &mut TestAppContext) { let (panel, mut cx) = setup_visible_panel(cx).await; @@ -10442,6 +11015,18 @@ mod tests { #[gpui::test] async fn test_terminal_notification_dismissed_when_sidebar_opens(cx: &mut TestAppContext) { let (panel, mut cx) = setup_visible_panel(cx).await; + // The sidebar starts open by default; this test needs it closed so the + // bell pops up a notification that opening the sidebar can dismiss. + cx.update(|window, cx| { + let multi_workspace = window + .root::() + .flatten() + .expect("test window should have a MultiWorkspace root"); + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.close_sidebar(window, cx); + }); + }); + cx.run_until_parked(); let first_terminal_id = panel .update_in(&mut cx, |panel, window, cx| { panel.insert_test_terminal("Build", true, window, cx) @@ -10713,6 +11298,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project_a", json!({ "file.txt": "" })) .await; fs.insert_tree("/project_b", json!({ "file.txt": "" })) @@ -11551,6 +12137,60 @@ mod tests { }); } + #[gpui::test] + async fn test_select_agent_action_updates_visible_draft(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); + }); + + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + }); + + cx.dispatch_action(SelectAgent { + agent: "my-configured-agent".to_string(), + }); + cx.run_until_parked(); + + let expected_agent = Agent::Custom { + id: "my-configured-agent".into(), + }; + + panel.read_with(cx, |panel, cx| { + let draft = panel.draft_thread.as_ref().expect("draft should exist"); + assert_eq!(panel.selected_agent, expected_agent); + assert_eq!(*draft.read(cx).agent_key(), expected_agent); + }); + + let kvp = cx.update(|_, cx| KeyValueStore::global(cx)); + assert_eq!( + read_global_last_used_agent(&kvp), + Some(expected_agent), + "the selection should be persisted as the global last-used agent" + ); + } + #[gpui::test] async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) { init_test(cx); @@ -11652,6 +12292,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project", json!({ "file.txt": "" })).await; let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; @@ -12158,8 +12799,18 @@ mod tests { // into `retained_threads` (keeping the user's prompt accessible // from the sidebar) and a fresh empty draft on the new agent // should become active. - cx.dispatch_action(NewExternalAgentThread { - agent: Agent::Stub.id(), + // Invoke the panel handler directly: dispatching the action at the + // window level now routes to the workspace handler, which creates an + // AgentThreadItem in the center pane instead of touching the panel's + // draft slot under test here. + panel.update_in(cx, |panel, window, cx| { + panel.new_external_agent_thread( + &NewExternalAgentThread { + agent: Agent::Stub.id(), + }, + window, + cx, + ); }); cx.run_until_parked(); @@ -12688,6 +13339,7 @@ mod tests { // Create a project with a file so we have a buffer in the center pane. let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project", json!({ "file.txt": "hello world" })) .await; let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; @@ -13148,6 +13800,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project_a", json!({ "file.txt": "" })) .await; fs.insert_tree("/project_b", json!({ "file.txt": "" })) @@ -13242,6 +13895,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project_a", json!({ "file.txt": "" })) .await; fs.insert_tree("/project_b", json!({ "file.txt": "" })) @@ -13274,10 +13928,11 @@ mod tests { panel.selected_agent = Agent::Stub; }); + // Do not add the panel to the workspace yet: adding it to the panel + // pane activates it, which eagerly initializes a draft thread. This + // test needs a genuinely fresh (never-activated) destination panel. let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); let initialized = panel_b.update_in(cx, |panel, window, cx| { @@ -13398,6 +14053,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); fs.insert_tree("/project_a", json!({ "file.txt": "" })) .await; fs.insert_tree("/project_b", json!({ "file.txt": "" })) diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 897a853662407c..a4fb6bfc60eae8 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -514,19 +514,27 @@ impl AgentRegistryPage { .size(IconSize::Small) .color(Color::Muted), ) - .on_click(move |_, _, cx| { - let agent_id = agent_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let agent_servers = settings.agent_servers.get_or_insert_default(); - agent_servers.entry(agent_id).or_insert_with(|| { - settings::CustomAgentServerSettings::Registry { - default_mode: None, - env: Default::default(), - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - } - }); + .on_click(move |_, window, cx| { + update_settings_file(fs.clone(), cx, { + let agent_id = agent_id.clone(); + move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + agent_servers.entry(agent_id).or_insert_with(|| { + settings::CustomAgentServerSettings::Registry { + default_mode: None, + env: Default::default(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + }); + } }); + window.dispatch_action( + Box::new(zed_actions::agent::SelectAgent { + agent: agent_id.clone(), + }), + cx, + ); }) } RegistryInstallStatus::InstalledRegistry => { diff --git a/crates/agent_ui/src/agent_thread_item.rs b/crates/agent_ui/src/agent_thread_item.rs index 27614ada16b9bb..85dcd60466f208 100644 --- a/crates/agent_ui/src/agent_thread_item.rs +++ b/crates/agent_ui/src/agent_thread_item.rs @@ -1,6 +1,6 @@ use std::rc::Rc; -use acp_thread::{AcpThread, ThreadStatus}; +use acp_thread::AcpThread; use agent::{NativeAgentServer, ThreadStore}; use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; @@ -214,24 +214,13 @@ impl AgentThreadItem { if conversation_view.is_draft(cx) { return None; } - let has_pending_tool_call = conversation_view.root_thread_has_pending_tool_call(cx); + let status = conversation_view.root_thread_display_status(cx)?; let thread_id = conversation_view.parent_id(); let thread_view = conversation_view.root_thread_view()?; let thread_view = thread_view.read(cx); let thread = thread_view.thread.read(cx); let title = conversation_view.title(cx); - let status = if has_pending_tool_call { - AgentThreadStatus::WaitingForConfirmation - } else if thread.had_error() { - AgentThreadStatus::Error - } else { - match thread.status() { - ThreadStatus::Generating => AgentThreadStatus::Running, - ThreadStatus::Idle => AgentThreadStatus::Completed, - } - }; - Some(AgentThreadInfo { thread_id, session_id: thread.session_id().clone(), @@ -887,6 +876,365 @@ impl Domain for AgentThreadItemDb { db::static_connection!(AgentThreadItemDb, [workspace::WorkspaceDb]); +#[cfg(test)] +mod tests { + use super::*; + use crate::conversation_view::tests::init_test; + use crate::test_support::StubAgentServer; + use acp_thread::StubAgentConnection; + use agent_settings::AgentSettings; + use fs::FakeFs; + use gpui::{TestAppContext, VisualTestContext}; + use serde_json::json; + use settings::{NotifyWhenAgentWaiting, Settings as _}; + use std::cell::RefCell; + use std::path::Path; + use workspace::MultiWorkspace; + + async fn setup_workspace( + cx: &mut TestAppContext, + ) -> (Entity, Entity, VisualTestContext) { + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ThreadMetadataStore::init_global(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs, [Path::new("/project")], cx).await; + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .expect("test window should expose a workspace"); + let cx = VisualTestContext::from_window(multi_workspace.into(), cx); + (workspace, project, cx) + } + + fn disable_agent_notifications(cx: &mut VisualTestContext) { + cx.update(|_window, cx| { + AgentSettings::override_global( + AgentSettings { + notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, + ..AgentSettings::get_global(cx).clone() + }, + cx, + ); + }); + } + + fn open_thread_item( + workspace: &Entity, + agent_id: &str, + connection: StubAgentConnection, + cx: &mut VisualTestContext, + ) -> Entity { + let item = workspace.update_in(cx, |workspace, window, cx| { + let server = StubAgentServer::new(connection.with_agent_id(AgentId::new(agent_id))) + .with_connection_agent_id(); + let item = build_agent_thread_item_for_options( + workspace, + Agent::Custom { + id: AgentId::new(agent_id), + }, + Some(Rc::new(server)), + None, + None, + None, + None, + None, + AgentThreadSource::Sidebar, + window, + cx, + ); + workspace.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + item + }); + cx.run_until_parked(); + item + } + + fn send_message_in_item( + item: &Entity, + text: &str, + cx: &mut VisualTestContext, + ) { + let thread_view = item.read_with(cx, |item, cx| { + item.conversation_view() + .read(cx) + .root_thread_view() + .expect("item should have a root thread view") + }); + let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text(text, window, cx); + }); + thread_view.update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); + } + + fn set_thread_title(item: &Entity, title: &str, cx: &mut VisualTestContext) { + let thread_id = item.read_with(cx, |item, cx| item.thread_id(cx)); + cx.update(|_window, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.set_title_override(thread_id, SharedString::from(title.to_string()), cx); + }); + }); + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_agent_thread_tab_lifecycle(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, _project, mut cx) = setup_workspace(cx).await; + let cx = &mut cx; + disable_agent_notifications(cx); + + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response A".into()), + )]); + let item_a = open_thread_item(&workspace, "agent-a", connection_a, cx); + send_message_in_item(&item_a, "Hello A", cx); + set_thread_title(&item_a, "Thread A", cx); + + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response B".into()), + )]); + let item_b = open_thread_item(&workspace, "agent-b", connection_b, cx); + send_message_in_item(&item_b, "Hello B", cx); + set_thread_title(&item_b, "Thread B", cx); + + let pane = workspace.read_with(cx, |workspace, _cx| workspace.active_pane().clone()); + pane.read_with(cx, |pane, _cx| { + assert_eq!( + pane.items_len(), + 2, + "both agent threads should be open as tabs in the pane" + ); + }); + + cx.update(|window, cx| { + assert_eq!(item_a.read(cx).tab_content_text(0, cx), "Thread A"); + assert_eq!(item_b.read(cx).tab_content_text(0, cx), "Thread B"); + assert!( + item_a.read(cx).tab_icon(window, cx).is_some(), + "thread tabs should have an agent icon" + ); + assert!(item_b.read(cx).tab_icon(window, cx).is_some()); + assert_eq!( + item_a.read(cx).conversation_view().read(cx).agent_key(), + &Agent::Custom { + id: AgentId::new("agent-a") + } + ); + assert_eq!( + item_b.read(cx).conversation_view().read(cx).agent_key(), + &Agent::Custom { + id: AgentId::new("agent-b") + } + ); + }); + + let weak_b = item_b.downgrade(); + pane.update_in(cx, |pane, window, cx| { + pane.close_item_by_id(item_b.entity_id(), SaveIntent::Close, window, cx) + }) + .await + .expect("closing an agent thread tab should succeed"); + drop(item_b); + cx.run_until_parked(); + + pane.read_with(cx, |pane, _cx| { + assert_eq!( + pane.items_len(), + 1, + "closing a tab should remove the item from the pane" + ); + }); + assert!( + !weak_b.is_upgradable(), + "closing a tab should drop the item entity" + ); + cx.update(|_window, cx| { + assert_eq!(item_a.read(cx).tab_content_text(0, cx), "Thread A"); + }); + } + + #[gpui::test] + async fn test_agent_thread_item_serialize_round_trip(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, project, mut cx) = setup_workspace(cx).await; + let cx = &mut cx; + disable_agent_notifications(cx); + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); + let item = open_thread_item(&workspace, "stub", connection, cx); + send_message_in_item(&item, "Hello", cx); + + // Adding the item schedules a throttled workspace serialization; drain + // it so the workspaces row exists before the item row references it. + cx.executor() + .advance_clock(workspace::SERIALIZATION_THROTTLE_TIME * 2); + cx.run_until_parked(); + + let thread_id = item.read_with(cx, |item, cx| item.thread_id(cx)); + let workspace_id = workspace + .read_with(cx, |workspace, _cx| workspace.database_id()) + .expect("workspace should have a database id"); + let item_id = item.entity_id().as_u64() as ItemId; + + let serialize_task = workspace.update_in(cx, |workspace, window, cx| { + item.update(cx, |item, cx| { + item.serialize(workspace, item_id, false, window, cx) + }) + .expect("agent thread items should serialize") + }); + serialize_task + .await + .expect("serializing the item should succeed"); + cx.run_until_parked(); + + let restored = cx + .update(|window, cx| { + AgentThreadItem::deserialize( + project.clone(), + workspace.downgrade(), + workspace_id, + item_id, + window, + cx, + ) + }) + .await + .expect("deserializing the item should restore the thread tab"); + cx.run_until_parked(); + + let restored_thread_id = restored.read_with(cx, |item, cx| item.thread_id(cx)); + assert_eq!( + restored_thread_id, thread_id, + "the restored tab should point at the same thread" + ); + } + + #[gpui::test] + async fn test_tab_updates_on_run_state_transitions(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, _project, mut cx) = setup_workspace(cx).await; + let cx = &mut cx; + disable_agent_notifications(cx); + + let connection = StubAgentConnection::new(); + let item = open_thread_item(&workspace, "agent-a", connection.clone(), cx); + + let update_tab_count = Rc::new(RefCell::new(0_usize)); + let _subscription = cx.update(|_window, cx| { + cx.subscribe(&item, { + let update_tab_count = update_tab_count.clone(); + move |_item, event: &ItemEvent, _cx| { + if matches!(event, ItemEvent::UpdateTab) { + *update_tab_count.borrow_mut() += 1; + } + } + }) + }); + let take_count = |count: &Rc>| std::mem::take(&mut *count.borrow_mut()); + + send_message_in_item(&item, "Hello", cx); + assert!( + take_count(&update_tab_count) > 0, + "starting a turn should update the tab" + ); + item.read_with(cx, |item, cx| { + assert_eq!( + item.conversation_view().read(cx).root_thread_run_state(cx), + crate::ThreadRunState::Running + ); + }); + + let thread = item.read_with(cx, |item, cx| { + item.root_thread(cx).expect("root thread should exist") + }); + let session_id = thread.read_with(cx, |thread, _cx| thread.session_id().clone()); + let (elicitation_id, _response_task) = thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("elicitation request should be accepted") + }); + cx.run_until_parked(); + + assert!( + take_count(&update_tab_count) > 0, + "an elicitation request should update the tab" + ); + item.read_with(cx, |item, cx| { + assert_eq!( + item.conversation_view().read(cx).root_thread_run_state(cx), + crate::ThreadRunState::ParkedOnHuman(crate::ParkedReason::Elicitation) + ); + assert_eq!( + item.active_thread_info(cx) + .expect("active thread info should exist") + .status, + AgentThreadStatus::WaitingForConfirmation + ); + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + cx.run_until_parked(); + assert!( + take_count(&update_tab_count) > 0, + "an elicitation response should update the tab" + ); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + assert!( + take_count(&update_tab_count) > 0, + "finishing the turn should update the tab" + ); + item.read_with(cx, |item, cx| { + assert_eq!( + item.conversation_view().read(cx).root_thread_run_state(cx), + crate::ThreadRunState::Idle + ); + assert_eq!( + item.active_thread_info(cx) + .expect("active thread info should exist") + .status, + AgentThreadStatus::Completed + ); + }); + } +} + impl AgentThreadItemDb { async fn save_thread_id( &self, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 64fbe1b01719a5..d76519d9149180 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -36,6 +36,7 @@ pub mod thread_worktree_archive; pub mod threads_archive_view; mod ui; +mod unicode_confusables; use std::rc::Rc; use std::sync::Arc; @@ -48,8 +49,8 @@ use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use feature_flags::FeatureFlagAppExt as _; use fs::Fs; use gpui::{ - Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, TaskExt, Window, - actions, + Action, App, Context, Entity, ImageSource, ReadGlobal as _, Resource, SharedString, SharedUri, + TaskExt, Window, actions, }; use language::{ LanguageRegistry, @@ -66,13 +67,13 @@ use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide}; use std::any::TypeId; use std::path::{Path, PathBuf}; -use workspace::{Workspace, register_serializable_item}; +use workspace::{OpenOptions, Workspace, register_serializable_item}; -use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; +use crate::agent_configuration::ManageProfilesModal; pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; pub use crate::agent_panel::{ - AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId, - ThreadTitleRegenerationResult, + AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, + TerminalAgentStatus, TerminalId, ThreadTitleRegenerationResult, }; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::agent_thread_item::{ @@ -84,7 +85,7 @@ pub use crate::message_editor::MessageEditorEvent; pub use crate::thread_metadata_store::ThreadId; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; pub use conversation_view::open_markdown_in_workspace; -pub use conversation_view::{ConversationView, StateChange}; +pub use conversation_view::{ConversationView, ParkedReason, StateChange, ThreadRunState}; pub use external_source_prompt::ExternalSourcePrompt; pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; @@ -123,40 +124,68 @@ pub(crate) fn resolve_agent_image( None } +/// Opens `abs_path` in the workspace, moving the cursor to `point` when one +/// is given. Paths outside every worktree are only opened when a file exists +/// there, so broken agent links don't create empty buffers. pub(crate) fn open_abs_path_at_point( workspace: &mut Workspace, abs_path: PathBuf, - point: Point, + point: Option, window: &mut Window, cx: &mut Context, -) -> bool { - let project = workspace.project(); - let Some(path) = project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return false; - }; - - let item = workspace.open_path(path, None, true, window, cx); +) { + let project_path = workspace + .project() + .update(cx, |project, cx| project.find_project_path(&abs_path, cx)); + let fs = workspace.project().read(cx).fs().clone(); + let workspace = cx.weak_entity(); window .spawn(cx, async move |cx| { - let Some(editor) = item.await?.downcast::() else { + let item = if let Some(project_path) = project_path { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(project_path, None, true, window, cx) + })? + .await? + } else { + let metadata = fs.metadata(&abs_path).await?; + anyhow::ensure!( + metadata.is_some_and(|metadata| !metadata.is_dir), + "no file found at path {abs_path:?}" + ); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + abs_path, + OpenOptions { + focus: Some(true), + ..Default::default() + }, + window, + cx, + ) + })? + .await? + }; + let Some(point) = point else { + return Ok(()); + }; + let Some(editor) = item.downcast::() else { return Ok(()); }; - let range = point..point; editor .update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, - |selections| selections.select_ranges([range]), + |selections| selections.select_ranges([point..point]), ); }) .ok(); anyhow::Ok(()) }) .detach_and_log_err(cx); - true } pub const DEFAULT_THREAD_TITLE: &str = "New thread"; @@ -358,49 +387,6 @@ pub struct ToggleCommandPattern { #[serde(deny_unknown_fields)] pub struct NewThread; -/// The kind of context server to configure when adding a new one. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ContextServerType { - /// A context server that runs locally via stdin/stdout. - #[default] - Local, - /// A context server that is connected to over HTTP. - Remote, -} - -/// Adds a context server to the configuration. -#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] -#[action(namespace = agent)] -#[serde(deny_unknown_fields)] -pub struct AddContextServer { - /// The kind of context server to add. - #[serde(default)] - pub context_server_type: ContextServerType, -} - -impl Default for AddContextServer { - fn default() -> Self { - Self::local() - } -} - -impl AddContextServer { - /// Returns an action that adds a local (stdin/stdout) context server. - pub fn local() -> Self { - Self { - context_server_type: ContextServerType::Local, - } - } - - /// Returns an action that adds a remote (HTTP) context server. - pub fn remote() -> Self { - Self { - context_server_type: ContextServerType::Remote, - } - } -} - /// Creates a new external agent conversation thread. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] @@ -634,16 +620,12 @@ pub fn init( } agent_panel::init(cx); register_serializable_item::(cx); - context_server_configuration::init(language_registry.clone(), fs.clone(), cx); + context_server_configuration::init(language_registry, fs.clone(), cx); thread_metadata_store::init(cx); terminal_thread_metadata_store::init(cx); inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); terminal_inline_assistant::init(fs.clone(), prompt_builder, cx); - cx.observe_new(move |workspace, window, cx| { - ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) - }) - .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { workspace.register_action( move |workspace: &mut Workspace, @@ -910,11 +892,12 @@ fn init_language_model_settings(cx: &mut App) { .detach(); cx.subscribe( &LanguageModelRegistry::global(cx), - |_, event: &language_model::Event, cx| match event { + |registry, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) | language_model::Event::ProvidersChanged => { + registry.update(cx, |registry, cx| registry.refresh_fallback_model(cx)); update_active_language_model_from_settings(cx); } _ => {} @@ -933,6 +916,12 @@ fn update_active_language_model_from_settings(cx: &mut App) { } } + let should_use_fallback = SettingsStore::global(cx) + .raw_user_settings() + .and_then(|user| user.content.agent.as_ref()) + .and_then(|agent| agent.default_model.as_ref()) + .is_none(); + let default = settings.default_model.as_ref().map(to_selected_model); let inline_assistant = settings .inline_assistant_model @@ -958,6 +947,7 @@ fn update_active_language_model_from_settings(cx: &mut App) { registry.select_commit_message_model(commit_message.as_ref(), cx); registry.select_thread_summary_model(thread_summary.as_ref(), cx); registry.select_inline_alternative_models(inline_alternatives, cx); + registry.set_should_use_fallback(should_use_fallback); }); } diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1fee158f4a16b2..022bf5c65858c3 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -525,18 +525,18 @@ impl CodegenAlternative { messages.push(user_message); let tools = vec![ - LanguageModelRequestTool { - name: REWRITE_SECTION_TOOL_NAME.to_string(), - description: "Replaces text in tags with your replacement_text.".to_string(), - input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), - use_input_streaming: false, - }, - LanguageModelRequestTool { - name: FAILURE_MESSAGE_TOOL_NAME.to_string(), - description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), - input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), - use_input_streaming: false, - }, + LanguageModelRequestTool::function( + REWRITE_SECTION_TOOL_NAME.to_string(), + "Replaces text in tags with your replacement_text.".to_string(), + language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + false, + ), + LanguageModelRequestTool::function( + FAILURE_MESSAGE_TOOL_NAME.to_string(), + "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), + language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + false, + ), ]; LanguageModelRequest { @@ -1179,9 +1179,7 @@ impl CodegenAlternative { let mut chars_read_by_tool_id = chars_read_by_tool_id.lock(); match tool_use.name.as_ref() { REWRITE_SECTION_TOOL_NAME => { - let Ok(input) = - serde_json::from_value::(tool_use.input) - else { + let Ok(input) = tool_use.input.parse::() else { return None; }; let chars_read_so_far = @@ -1198,9 +1196,7 @@ impl CodegenAlternative { }) } FAILURE_MESSAGE_TOOL_NAME => { - let Ok(mut input) = - serde_json::from_value::(tool_use.input) - else { + let Ok(mut input) = tool_use.input.parse::() else { return None; }; Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) @@ -2011,7 +2007,9 @@ mod tests { id: id.into(), name: REWRITE_SECTION_TOOL_NAME.into(), raw_input: serde_json::to_string(&input).unwrap(), - input: serde_json::to_value(&input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&input).unwrap(), + ), is_input_complete: is_complete, thought_signature: None, }) diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index ba2d50f7ba2c1d..e92e06c4255b7d 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -184,6 +184,51 @@ impl PromptContextAction { } } +/// A slash command that runs a local UI action against the conversation +/// (sending feedback) instead of being sent to the agent as part of a prompt. +/// Each variant maps to a method on `ThreadView`; the completion provider only +/// surfaces them and emits an event, while `ThreadView` performs the actual +/// work (see `handle_message_editor_event`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptLocalCommand { + ThumbsUp, + ThumbsDown, +} + +impl PromptLocalCommand { + pub fn keyword(&self) -> &'static str { + match self { + Self::ThumbsUp => "helpful", + Self::ThumbsDown => "not-helpful", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::ThumbsUp => "Positive Feedback", + Self::ThumbsDown => "Negative Feedback", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::ThumbsUp => { + "Rate this response as helpful. Sends the current conversation to the Zed team." + } + Self::ThumbsDown => { + "Rate this response as not helpful. Sends the current conversation to the Zed team." + } + } + } + + pub fn icon(&self) -> IconName { + match self { + Self::ThumbsUp => IconName::ThumbsUp, + Self::ThumbsDown => IconName::ThumbsDown, + } + } +} + impl TryFrom<&str> for PromptContextType { type Error = String; @@ -360,13 +405,15 @@ impl AvailableCommand { enum SlashCompletionCandidate { Skill(AvailableSkill), Command(AvailableCommand), + LocalCommand(PromptLocalCommand), } impl SlashCompletionCandidate { - fn name(&self) -> &Arc { + fn name(&self) -> &str { match self { Self::Skill(skill) => &skill.name, Self::Command(command) => &command.name, + Self::LocalCommand(command) => command.keyword(), } } } @@ -379,6 +426,7 @@ fn slash_completion_group_key(candidate: &SlashCompletionCandidate) -> u32 { match candidate { SlashCompletionCandidate::Skill(_) => 0, SlashCompletionCandidate::Command(command) => 1 + command.category_order() as u32, + SlashCompletionCandidate::LocalCommand(_) => 4, } } @@ -405,6 +453,13 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { fn available_skills(&self, _cx: &App) -> Vec { Vec::new() } + + fn available_local_commands(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn run_local_command(&self, _command: PromptLocalCommand, _cx: &mut App) {} + fn confirm_command(&self, cx: &mut App); /// Called once each time the user opens slash-command autocomplete @@ -953,15 +1008,21 @@ impl PromptCompletionProvider { let mut candidates = self .source - .available_skills(cx) + .available_commands(cx) .into_iter() - .map(SlashCompletionCandidate::Skill) + .map(SlashCompletionCandidate::Command) .collect::>(); candidates.extend( self.source - .available_commands(cx) + .available_skills(cx) .into_iter() - .map(SlashCompletionCandidate::Command), + .map(SlashCompletionCandidate::Skill), + ); + candidates.extend( + self.source + .available_local_commands(cx) + .into_iter() + .map(SlashCompletionCandidate::LocalCommand), ); if candidates.is_empty() { return Task::ready(Vec::new()); @@ -1423,7 +1484,10 @@ impl CompletionProvider for PromptCompletio Some((new_text, icon_path, icon_color, confirm)), ) } - SlashCompletionCandidate::Command(_) => (candidate, None), + SlashCompletionCandidate::Command(_) + | SlashCompletionCandidate::LocalCommand(_) => { + (candidate, None) + } }) .collect::)>>() }) @@ -1536,6 +1600,50 @@ impl CompletionProvider for PromptCompletio group, } } + SlashCompletionCandidate::LocalCommand(command) => { + let group = show_section_headers.then(|| CompletionGroup { + key: "local-commands".into(), + label: Some("Actions".into()), + }); + + Completion { + replace_range: source_range.clone(), + // Local commands aren't part of the prompt; + // confirming one clears the typed text + // rather than leaving `/keyword` behind. + new_text: String::new(), + label: CodeLabel::plain(command.label().to_string(), None), + documentation: Some( + CompletionDocumentation::MultiLinePlainText( + command.description().into(), + ), + ), + source: project::CompletionSource::Custom, + icon_path: Some(command.icon().path().into()), + icon_color: None, + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: Some(Arc::new({ + let source = source.clone(); + move |intent, _window, cx| { + cx.defer({ + let source = source.clone(); + move |cx| match intent { + CompletionIntent::Complete + | CompletionIntent::CompleteWithInsert + | CompletionIntent::CompleteWithReplace => { + source.run_local_command(command, cx); + } + CompletionIntent::Compose => {} + } + }); + false + } + })), + group, + } + } }) .collect(); @@ -2109,7 +2217,7 @@ fn diagnostics_crease_label( diagnostics_label(summary, include_errors, include_warnings).into() } -fn pluralize(noun: &str, count: usize) -> String { +pub(crate) fn pluralize(noun: &str, count: usize) -> String { if count == 1 { noun.to_string() } else { diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 70d1b0bfa0d6c7..d42d4e45594ec1 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -5,7 +5,6 @@ use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use collections::HashSet; -use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use fuzzy::StringMatchCandidate; use gpui::{ @@ -99,9 +98,8 @@ impl ConfigOptionsView { favorites_only: bool, cx: &mut Context, ) -> bool { - let render_boolean_config_options = should_render_boolean_config_options(cx); let Some(config_id) = self.first_config_option_id_matching(category, |option| { - Self::can_cycle_config_option(option, favorites_only, render_boolean_config_options) + Self::can_cycle_config_option(option, favorites_only) }) else { return false; }; @@ -144,14 +142,10 @@ impl ConfigOptionsView { .map(|option| option.id) } - fn can_cycle_config_option( - option: &acp::SessionConfigOption, - favorites_only: bool, - render_boolean_config_options: bool, - ) -> bool { + fn can_cycle_config_option(option: &acp::SessionConfigOption, favorites_only: bool) -> bool { match &option.kind { acp::SessionConfigKind::Select(_) => true, - acp::SessionConfigKind::Boolean(_) => !favorites_only && render_boolean_config_options, + acp::SessionConfigKind::Boolean(_) => !favorites_only, _ => false, } } @@ -213,7 +207,7 @@ impl ConfigOptionsView { )) } acp::SessionConfigKind::Boolean(boolean) => { - if favorites_only || !should_render_boolean_config_options(cx) { + if favorites_only { None } else { Some(acp::SessionConfigOptionValue::boolean( @@ -545,10 +539,6 @@ impl Render for ConfigOptionSelector { .into_any_element() } acp::SessionConfigKind::Boolean(boolean) => { - if !should_render_boolean_config_options(cx) { - return div().into_any_element(); - } - let option_id = option.id.clone(); let option_name: SharedString = option.name.clone().into(); let option_description: Option = @@ -993,10 +983,6 @@ fn setting_value_for_config_option_value( } } -fn should_render_boolean_config_options(cx: &App) -> bool { - cx.has_flag::() -} - fn options_to_picker_entries( options: &[ConfigOptionValue], favorites: &HashSet, @@ -1112,9 +1098,8 @@ fn count_config_options(option: &acp::SessionConfigOption) -> usize { mod tests { use super::*; use acp_thread::AgentConnection; - use feature_flags::FeatureFlag as _; use fs::FakeFs; - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::TestAppContext; use parking_lot::Mutex; use project::{AgentId, Project}; use std::{any::Any, cell::RefCell}; @@ -1180,9 +1165,6 @@ mod tests { let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx); - let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); @@ -1217,41 +1199,7 @@ mod tests { } #[gpui::test] - fn cycling_hidden_boolean_config_option_is_unhandled(cx: &mut TestAppContext) { - let agent_server = Rc::new(TestAgentServer::default()); - let config_options = Rc::new(TestSessionConfigOptions::new(vec![ - acp::SessionConfigOption::boolean("web_search", "Web Search", false) - .category(acp::SessionConfigOptionCategory::ModelConfig), - ])); - let fs: Arc = FakeFs::new(cx.executor()); - - cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - - let config_options: Rc = config_options.clone(); - let agent_server: Rc = agent_server.clone(); - let fs = fs.clone(); - let view = cx.new(|_| ConfigOptionsView { - config_option_ids: ConfigOptionsView::config_option_ids(&config_options), - config_options, - selectors: Vec::new(), - agent_server, - fs, - _refresh_task: Task::ready(()), - }); - - assert!(!view.update(cx, |view, cx| { - view.cycle_category_option(acp::SessionConfigOptionCategory::ModelConfig, false, cx) - })); - }); - - assert!(agent_server.saved_defaults.lock().is_empty()); - assert!(config_options.set_values.borrow().is_empty()); - } - - #[gpui::test] - fn cycling_category_skips_hidden_boolean_config_option(cx: &mut TestAppContext) { + fn cycling_category_cycles_boolean_config_option_first(cx: &mut TestAppContext) { let agent_server = Rc::new(TestAgentServer::default()); let config_options = Rc::new(TestSessionConfigOptions::new(vec![ acp::SessionConfigOption::boolean("web_search", "Web Search", false) @@ -1270,9 +1218,6 @@ mod tests { let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); @@ -1293,15 +1238,15 @@ mod tests { assert_eq!( agent_server.saved_defaults.lock().as_slice(), &[( - "model".to_string(), - Some(AgentConfigOptionValue::ValueId("large".to_string())) + "web_search".to_string(), + Some(AgentConfigOptionValue::Boolean(true)) )] ); assert_eq!( config_options.set_values.borrow().as_slice(), &[( - "model".to_string(), - acp::SessionConfigOptionValue::value_id("large") + "web_search".to_string(), + acp::SessionConfigOptionValue::boolean(true) )] ); } @@ -1332,45 +1277,11 @@ mod tests { assert!(!handled); } - #[gpui::test] - fn boolean_config_option_rendering_is_beta_gated(cx: &mut TestAppContext) { - cx.update(|cx| { - init_feature_flag_settings(cx); - - cx.update_flags(false, Vec::new()); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - assert!(!should_render_boolean_config_options(cx)); - - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx); - assert!(should_render_boolean_config_options(cx)); - }); - } - #[derive(Default)] struct TestAgentServer { saved_defaults: Arc)>>>, } - fn init_feature_flag_settings(cx: &mut App) { - let store = SettingsStore::test(cx); - cx.set_global(store); - SettingsStore::update_global(cx, |store, _| { - store.register_setting::(); - }); - cx.update_flags(false, Vec::new()); - } - - fn set_feature_flag_override(name: &str, value: &str, cx: &mut App) { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |content| { - content - .feature_flags - .get_or_insert_default() - .insert(name.to_string(), value.to_string()); - }); - }); - } - impl AgentServer for TestAgentServer { fn logo(&self) -> IconName { IconName::ZedAssistant diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 3404a9bb5cbf28..5c95133a634a96 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1,8 +1,9 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, - AuthRequired, ClientUserMessageId, LoadError, MaxOutputTokensError, MentionUri, - PermissionOptionChoice, PermissionOptions, PermissionPattern, RetryStatus, - SelectedPermissionOutcome, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, + AuthRequired, ClientUserMessageId, ElicitationEntryId, ElicitationStatus, ElicitationStore, + LoadError, MaxOutputTokensError, MentionUri, PermissionOptionChoice, PermissionOptions, + PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, ToolCall, + ToolCallContent, ToolCallStatus, }; use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; @@ -26,15 +27,15 @@ use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; use gpui::{ - Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle, - ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, - ObjectFit, PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, - TextRun, TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, + Action, Animation, AnimationExt, App, ClickEvent, ClipboardItem, CursorStyle, ElementId, Empty, + Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit, + PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, TextRun, + TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, pulsating_between, }; use itertools::Itertools; use language::{Buffer, Language, Rope}; -use language_model::{LanguageModelCompletionError, LanguageModelRegistry}; +use language_model::LanguageModelCompletionError; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle, }; @@ -43,6 +44,9 @@ use project::{ AgentId, AgentRegistryStore, AgentServerStore, Project, ProjectEntryId, ProjectPath, }; +use crate::conversation_view::elicitation::{ + ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation, +}; use crate::message_editor::SessionCapabilities; use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image}; use lru::LruCache; @@ -64,7 +68,7 @@ use ui::{ }; use util::{ ResultExt, debug_panic, defer, - paths::{PathStyle, PathWithPosition}, + paths::{PathExt as _, PathStyle, PathWithPosition}, rel_path::RelPath, size::format_file_size, time::duration_alt_display, @@ -106,6 +110,7 @@ const TOKEN_THRESHOLD: u64 = 250; pub(crate) const DRAFT_PROMPT_PERSIST_DEBOUNCE: Duration = Duration::from_millis(250); +pub(crate) mod elicitation; mod message_queue; mod thread_search_bar; mod thread_view; @@ -247,12 +252,24 @@ impl ProfileProvider for Entity { fn model_selected(&self, cx: &App) -> bool { self.read(cx).model().is_some() } + + fn is_restricted(&self, cx: &App) -> bool { + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &self.read(cx).project().read(cx).worktree_store(), + cx, + ) + } + + fn profile_downgraded(&self, cx: &App) -> bool { + self.read(cx).profile_was_downgraded() + } } #[derive(Default)] pub(crate) struct Conversation { threads: HashMap>, permission_requests: IndexMap>, + elicitation_requests: IndexMap>, subscriptions: Vec, updated_at: Option, } @@ -279,6 +296,20 @@ impl Conversation { } } } + AcpThreadEvent::ElicitationRequested(id) => { + this.elicitation_requests + .entry(session_id.clone()) + .or_default() + .push(id.clone()); + } + AcpThreadEvent::ElicitationResponded(id) => { + if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) { + elicitations.retain(|elicitation_id| elicitation_id != id); + if elicitations.is_empty() { + this.elicitation_requests.shift_remove(&session_id); + } + } + } AcpThreadEvent::NewEntry | AcpThreadEvent::StatusChanged | AcpThreadEvent::TitleUpdated @@ -383,6 +414,34 @@ impl Conversation { .unwrap_or(0) } + /// Returns the first elicitation for `session_id` that is still waiting on + /// a response from the user. + pub fn pending_elicitation_for_session( + &self, + session_id: &acp::SessionId, + cx: &App, + ) -> Option { + let thread = self.threads.get(session_id)?; + let elicitation_id = self.elicitation_requests.get(session_id)?.iter().next()?; + let (_, elicitation) = thread.read(cx).elicitation(elicitation_id)?; + matches!(elicitation.status, ElicitationStatus::Pending { .. }) + .then(|| elicitation_id.clone()) + } + + pub fn respond_to_elicitation( + &mut self, + session_id: acp::SessionId, + elicitation_id: ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) -> Option<()> { + let thread = self.threads.get(&session_id)?.clone(); + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation(&elicitation_id, response, cx); + }); + Some(()) + } + pub fn authorize_pending_tool_call( &mut self, session_id: &acp::SessionId, @@ -479,6 +538,35 @@ pub struct StateChange; impl EventEmitter for ConversationView {} +/// Why a thread is parked waiting on a human. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParkedReason { + /// A tool call is waiting for the user to authorize it. + ToolCallConfirmation, + /// The agent asked the user a question (elicitation) and is waiting for an + /// answer. + Elicitation, +} + +/// The run state of an agent thread. Unlike [`acp_thread::ThreadStatus`], this +/// makes "the agent is blocked on a human" a first-class state instead of one +/// inferred from layout or entry contents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThreadRunState { + /// The agent is actively working on a turn. + Running, + /// The agent cannot make progress until a human responds. + ParkedOnHuman(ParkedReason), + /// No turn is in progress. + Idle, +} + +impl ThreadRunState { + pub fn is_parked_on_human(&self) -> bool { + matches!(self, ThreadRunState::ParkedOnHuman(_)) + } +} + fn resolve_outcome_from_selection( options: &PermissionOptions, selection: Option<&thread_view::PermissionSelection>, @@ -522,6 +610,11 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool { | AcpThreadEvent::TitleUpdated | AcpThreadEvent::ToolAuthorizationRequested(_) | AcpThreadEvent::ToolAuthorizationReceived(_) + | AcpThreadEvent::ElicitationRequested(_) + | AcpThreadEvent::ElicitationResponded(_) + // StatusChanged only fires on turn boundaries; including it keeps + // tab/sidebar statuses in sync with every Running <-> Idle transition. + | AcpThreadEvent::StatusChanged | AcpThreadEvent::Stopped(_) | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) @@ -529,7 +622,6 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool { | AcpThreadEvent::WorkingDirectoriesUpdated => true, // -- AcpThreadEvent::EntryUpdated(_) - | AcpThreadEvent::StatusChanged | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::Retry(_) | AcpThreadEvent::TokenUsageUpdated @@ -572,6 +664,7 @@ pub struct ConversationView { /// Cache + worktree snapshot for resolving paths in markdown code spans. /// Shared with the child [`ThreadView`] when one is constructed. pub(crate) code_span_resolver: AgentCodeSpanResolver, + request_elicitation_form_states: HashMap, _subscriptions: Vec, } @@ -620,6 +713,94 @@ impl ConversationView { }) } + pub fn root_thread_has_pending_elicitation(&self, cx: &App) -> bool { + let Some(root_thread) = self.root_thread_view() else { + return false; + }; + let root_session_id = root_thread.read(cx).thread.read(cx).session_id().clone(); + self.as_connected().is_some_and(|connected| { + connected + .conversation + .read(cx) + .pending_elicitation_for_session(&root_session_id, cx) + .is_some() + }) + } + + /// Single source of truth for the root thread's run state. Considers both + /// pending tool-call authorizations and pending elicitations as "parked on + /// a human". + pub fn root_thread_run_state(&self, cx: &App) -> ThreadRunState { + if self.root_thread_has_pending_tool_call(cx) { + return ThreadRunState::ParkedOnHuman(ParkedReason::ToolCallConfirmation); + } + if self.root_thread_has_pending_elicitation(cx) { + return ThreadRunState::ParkedOnHuman(ParkedReason::Elicitation); + } + match self.root_thread(cx).map(|thread| thread.read(cx).status()) { + Some(ThreadStatus::Generating) => ThreadRunState::Running, + Some(ThreadStatus::Idle) | None => ThreadRunState::Idle, + } + } + + /// Maps [`Self::root_thread_run_state`] (plus error and server-liveness + /// signals) onto the status shown in tabs and the sidebar. Returns `None` + /// when no root thread exists. + pub fn root_thread_display_status(&self, cx: &App) -> Option { + let root_thread = self.root_thread(cx)?; + let thread = root_thread.read(cx); + if !thread.server_alive() { + return Some(ui::AgentThreadStatus::Error); + } + Some(match self.root_thread_run_state(cx) { + ThreadRunState::ParkedOnHuman(_) => ui::AgentThreadStatus::WaitingForConfirmation, + _ if thread.had_error() => ui::AgentThreadStatus::Error, + ThreadRunState::Running => ui::AgentThreadStatus::Running, + ThreadRunState::Idle => ui::AgentThreadStatus::Completed, + }) + } + + /// Run states of subagent threads spawned by this conversation, keyed by + /// session. Intended as an orchestration hook; the sidebar/tab UI only + /// surfaces the root thread's state. + pub fn subagent_run_states(&self, cx: &App) -> Vec<(acp::SessionId, ThreadRunState)> { + let Some(connected) = self.as_connected() else { + return Vec::new(); + }; + let conversation = connected.conversation.read(cx); + connected + .threads + .iter() + .filter(|(_, thread_view)| { + thread_view + .read(cx) + .thread + .read(cx) + .parent_session_id() + .is_some() + }) + .map(|(session_id, thread_view)| { + let run_state = if conversation + .pending_tool_call_for_session(session_id, cx) + .is_some() + { + ThreadRunState::ParkedOnHuman(ParkedReason::ToolCallConfirmation) + } else if conversation + .pending_elicitation_for_session(session_id, cx) + .is_some() + { + ThreadRunState::ParkedOnHuman(ParkedReason::Elicitation) + } else { + match thread_view.read(cx).thread.read(cx).status() { + ThreadStatus::Generating => ThreadRunState::Running, + ThreadStatus::Idle => ThreadRunState::Idle, + } + }; + (session_id.clone(), run_state) + }) + .collect() + } + pub(crate) fn root_thread(&self, cx: &App) -> Option> { self.root_thread_view() .map(|view| view.read(cx).thread.clone()) @@ -686,6 +867,8 @@ enum ServerState { Loading { _loading: Entity, draft: Option, + connection: Option>, + _request_elicitation_subscription: Option, }, LoadError { error: LoadError, @@ -702,15 +885,14 @@ pub struct ConnectedServerState { connection: Rc, conversation: Entity, _connection_entry_subscription: Subscription, + _request_elicitation_subscription: Option, } enum AuthState { Ok, Unauthenticated { description: Option>, - configuration_view: Option, pending_auth_method: Option, - _subscription: Option, }, } @@ -808,6 +990,7 @@ impl ConversationView { })); cx.on_release(|this, cx| { + this.request_elicitation_form_states.clear(); if let Some(connected) = this.as_connected() { connected.close_all_sessions(cx).detach(); } @@ -847,8 +1030,8 @@ impl ConversationView { work_dirs, title, project, - workspace.clone(), - thread_store.clone(), + workspace, + thread_store, initial_content, source, window, @@ -861,16 +1044,29 @@ impl ConversationView { last_theme_id: Some(cx.theme().id.clone()), draft_prompt_persist_task: None, code_span_resolver, + request_elicitation_form_states: HashMap::default(), _subscriptions: subscriptions, focus_handle: cx.focus_handle(), } } fn set_server_state(&mut self, state: ServerState, cx: &mut Context) { + let previous_request_elicitation_connection = self.request_elicitation_connection(); + let next_request_elicitation_connection = + Self::request_elicitation_connection_for_state(&state); + if let Some(connected) = self.as_connected() { connected.close_all_sessions(cx).detach(); } + if let Some(connection) = previous_request_elicitation_connection + && !next_request_elicitation_connection + .as_ref() + .is_some_and(|next_connection| Rc::ptr_eq(&connection, next_connection)) + { + self.request_elicitation_form_states.clear(); + } + self.server_state = state; cx.emit(StateChange); cx.emit(AcpServerViewEvent::ActiveThreadChanged); @@ -880,6 +1076,53 @@ impl ConversationView { cx.notify(); } + fn request_elicitation_subscription( + connection: &Rc, + cx: &mut Context, + ) -> Option { + let store = connection.request_elicitations()?; + Some(cx.observe(&store, |this, _store, cx| { + if let Some(active_thread) = this.active_thread().cloned() { + active_thread.update(cx, |_thread, cx| cx.notify()); + } + cx.notify(); + })) + } + + fn request_elicitation_connection(&self) -> Option> { + Self::request_elicitation_connection_for_state(&self.server_state) + } + + fn active_thread_renders_request_elicitations(&self) -> bool { + match &self.server_state { + ServerState::Connected(connected) => { + connected.auth_state.is_ok() && connected.active_view().is_some() + } + _ => false, + } + } + + fn request_elicitation_connection_for_state( + state: &ServerState, + ) -> Option> { + match state { + ServerState::Loading { + connection: Some(connection), + .. + } => Some(connection.clone()), + ServerState::Connected(connected) => Some(connected.connection.clone()), + ServerState::Loading { + connection: None, .. + } + | ServerState::LoadError { .. } => None, + } + } + + fn request_elicitation_store(&self) -> Option> { + self.request_elicitation_connection()? + .request_elicitations() + } + fn reset(&mut self, window: &mut Window, cx: &mut Context) { let (resume_session_id, work_dirs, title) = self .root_thread_view() @@ -905,6 +1148,7 @@ impl ConversationView { (session_id, work_dirs, title) }); + self.clear_resolved_request_elicitations(cx); self.loading_status = None; let state = Self::initial_state( @@ -1270,9 +1514,9 @@ impl ConversationView { &agent, &connection_key, thread_id, - workspace.clone(), + workspace, project.downgrade(), - thread_store.clone(), + thread_store, initial_content.as_ref(), window, cx, @@ -1299,6 +1543,22 @@ impl ConversationView { } }; + this.update_in(cx, |this, _window, cx| { + let request_elicitation_subscription = + Self::request_elicitation_subscription(&connection, cx); + if let ServerState::Loading { + connection: loading_connection, + _request_elicitation_subscription, + .. + } = &mut this.server_state + { + *loading_connection = Some(connection.clone()); + *_request_elicitation_subscription = request_elicitation_subscription; + cx.notify(); + } + }) + .log_err(); + telemetry::event!( "Agent Thread Started", agent = connection.telemetry_id(), @@ -1351,14 +1611,7 @@ impl ConversationView { Err(e) => match e.downcast::() { Ok(err) => { cx.update(|window, cx| { - Self::handle_auth_required( - this, - err, - agent.agent_id(), - connection, - window, - cx, - ) + Self::handle_auth_required(this, err, connection, window, cx) }) .log_err(); return; @@ -1397,6 +1650,7 @@ impl ConversationView { this.update_in(cx, |this, window, cx| { match result { Ok(thread) => { + this.clear_resolved_request_elicitations_for_connection(&connection, cx); let root_session_id = thread.read(cx).session_id().clone(); let conversation = cx.new(|cx| { @@ -1423,6 +1677,8 @@ impl ConversationView { } this.root_session_id = Some(root_session_id.clone()); + let request_elicitation_subscription = + Self::request_elicitation_subscription(&connection, cx); this.set_server_state( ServerState::Connected(ConnectedServerState { connection, @@ -1431,6 +1687,7 @@ impl ConversationView { threads: HashMap::from_iter([(root_session_id, current)]), conversation, _connection_entry_subscription: connection_entry_subscription, + _request_elicitation_subscription: request_elicitation_subscription, }), cx, ); @@ -1454,6 +1711,8 @@ impl ConversationView { ServerState::Loading { _loading: loading_view, draft: loading_draft, + connection: None, + _request_elicitation_subscription: None, } } @@ -1662,55 +1921,17 @@ impl ConversationView { fn handle_auth_required( this: WeakEntity, err: AuthRequired, - agent_id: AgentId, connection: Rc, window: &mut Window, cx: &mut App, ) { - let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id { - let registry = LanguageModelRegistry::global(cx); - - let sub = window.subscribe(®istry, cx, { - let provider_id = provider_id.clone(); - let this = this.clone(); - move |_, ev, window, cx| { - if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev - && &provider_id == updated_provider_id - && LanguageModelRegistry::global(cx) - .read(cx) - .provider(&provider_id) - .map_or(false, |provider| provider.is_authenticated(cx)) - { - this.update(cx, |this, cx| { - this.reset(window, cx); - }) - .ok(); - } - } - }); - - let view = registry.read(cx).provider(&provider_id).map(|provider| { - provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_id.0), - window, - cx, - ) - }); - - (view, Some(sub)) - } else { - (None, None) - }; - this.update(cx, |this, cx| { let description = err .description .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))); let auth_state = AuthState::Unauthenticated { pending_auth_method: None, - configuration_view, description, - _subscription: subscription, }; if let Some(connected) = this.as_connected_mut() { connected.auth_state = auth_state; @@ -1725,6 +1946,8 @@ impl ConversationView { this.focus_handle.focus(window, cx) } } else { + let request_elicitation_subscription = + Self::request_elicitation_subscription(&connection, cx); this.set_server_state( ServerState::Connected(ConnectedServerState { auth_state, @@ -1733,6 +1956,7 @@ impl ConversationView { connection, conversation: cx.new(|_cx| Conversation::default()), _connection_entry_subscription: Subscription::new(|| {}), + _request_elicitation_subscription: request_elicitation_subscription, }), cx, ); @@ -1890,7 +2114,8 @@ impl ConversationView { ); }); active.update(cx, |active, cx| { - active.sync_editor_mode_for_empty_state(cx); + active.sync_elicitation_state_for_entry(index, window, cx); + active.sync_editor_mode(cx); active.sync_generating_indicator(cx); }); } @@ -1904,6 +2129,7 @@ impl ConversationView { }); list_state.remeasure_items(*index..*index + 1); active.update(cx, |active, cx| { + active.sync_elicitation_state_for_entry(*index, window, cx); active.auto_expand_streaming_thought(cx); active.sync_generating_indicator(cx); }); @@ -1916,7 +2142,7 @@ impl ConversationView { 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); + active.sync_editor_mode(cx); }); } } @@ -1927,6 +2153,10 @@ impl ConversationView { self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); } AcpThreadEvent::ToolAuthorizationReceived(_) => {} + AcpThreadEvent::ElicitationRequested(_) => { + self.notify_with_sound("Waiting for input", IconName::Info, window, cx); + } + AcpThreadEvent::ElicitationResponded(_) => {} AcpThreadEvent::Retry(retry) => { if let Some(active) = self.thread_view(&session_id) { active.update(cx, |active, _cx| { @@ -2187,7 +2417,6 @@ impl ConversationView { let connection = connected.connection.clone(); let AuthState::Unauthenticated { - configuration_view, pending_auth_method, .. } = &mut connected.auth_state @@ -2198,7 +2427,6 @@ impl ConversationView { let agent_telemetry_id = connection.telemetry_id(); if let Some(login_task) = connection.terminal_auth_task(&method, cx) { - configuration_view.take(); pending_auth_method.replace(method.clone()); let project = self.project.clone(); @@ -2238,6 +2466,7 @@ impl ConversationView { this.update_in(cx, |this, window, cx| { if let Err(err) = result { + this.cancel_request_elicitations(cx); if let Some(ConnectedServerState { auth_state: AuthState::Unauthenticated { @@ -2266,7 +2495,6 @@ impl ConversationView { return; } - configuration_view.take(); pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); @@ -2288,6 +2516,7 @@ impl ConversationView { this.update_in(cx, |this, window, cx| { if let Err(err) = result { + this.cancel_request_elicitations(cx); if let Some(ConnectedServerState { auth_state: AuthState::Unauthenticated { @@ -2529,7 +2758,6 @@ impl ConversationView { &self, connection: &Rc, description: Option<&Entity>, - configuration_view: Option<&AnyView>, pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, @@ -2542,10 +2770,8 @@ impl ConversationView { .agent_display_name(&self.agent.agent_id()) .unwrap_or_else(|| self.agent.agent_id().0); - let show_fallback_description = auth_methods.len() > 1 - && configuration_view.is_none() - && description.is_none() - && pending_auth_method.is_none(); + let show_fallback_description = + auth_methods.len() > 1 && description.is_none() && pending_auth_method.is_none(); let auth_buttons = || { h_flex().justify_end().flex_wrap().gap_1().children( @@ -2620,12 +2846,7 @@ impl ConversationView { .color(Color::Muted), ) } else { - this.children( - configuration_view - .cloned() - .map(|view| div().w_full().child(view)), - ) - .children(description.map(|desc| { + this.children(description.map(|desc| { self.render_markdown( desc.clone(), MarkdownStyle::themed(MarkdownFont::Agent, window, cx), @@ -2641,116 +2862,400 @@ impl ConversationView { .into_any_element() } - fn emit_load_error_telemetry(&self, error: &LoadError) { - let error_kind = match error { - LoadError::Unsupported { .. } => "unsupported", - LoadError::FailedToInstall(_) => "failed_to_install", - LoadError::Exited { .. } => "exited", - LoadError::Other(_) => "other", + fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.request_elicitation_store() else { + self.request_elicitation_form_states.clear(); + return; }; - let agent_name = self.agent.agent_id(); + let elicitations = store + .read(cx) + .elicitations() + .iter() + .map(|elicitation| { + let is_pending = matches!(elicitation.status, ElicitationStatus::Pending { .. }); + let schema = match &elicitation.request.mode { + acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()), + _ => None, + }; + (elicitation.id.clone(), is_pending, schema) + }) + .collect::>(); - telemetry::event!( - "Agent Panel Error Shown", - agent = agent_name, - kind = error_kind, - message = error.to_string(), - ); + let known_ids = elicitations + .iter() + .map(|(id, _, _)| id.clone()) + .collect::>(); + self.request_elicitation_form_states + .retain(|id, _| known_ids.contains(id)); + + for (id, is_pending, schema) in elicitations { + if is_pending + && let Some(schema) = schema + && !self.request_elicitation_form_states.contains_key(&id) + { + self.request_elicitation_form_states + .insert(id, ElicitationFormState::new(&schema, window, cx)); + } else if !is_pending { + self.request_elicitation_form_states.remove(&id); + } + } } - fn render_load_error( + fn render_request_elicitations( &self, - e: &LoadError, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let (title, message, action_slot): (_, SharedString, _) = match e { - LoadError::Unsupported { - command: path, - current_version, - minimum_version, - } => { - return self.render_unsupported(path, current_version, minimum_version, window, cx); - } - LoadError::FailedToInstall(msg) => ( - "Failed to Install", - msg.into(), - Some(self.create_copy_button(msg.to_string()).into_any_element()), - ), - LoadError::Exited { status, stderr } => { - let mut message = format!("Server exited with status {status}"); - if let Some(stderr) = stderr { - message.push_str("\n"); - message.push_str(stderr); - }; - let action_slot = stderr - .is_some() - .then(|| self.create_copy_button(message.clone()).into_any_element()); - ("Failed to Launch", message.into(), action_slot) - } - LoadError::Other(msg) => ( - "Failed to Launch", - msg.into(), - Some(self.create_copy_button(msg.to_string()).into_any_element()), - ), + connection: &Rc, + view: WeakEntity, + cx: &App, + ) -> Vec { + let Some(store) = connection.request_elicitations() else { + return Vec::new(); }; - Callout::new() - .severity(Severity::Error) - .icon(IconName::XCircleFilled) - .title(title) - .description(message) - .actions_slot(div().children(action_slot)) - .into_any_element() - } + let handlers = Self::request_elicitation_card_handlers(view); - fn render_unsupported( - &self, - path: &SharedString, - version: &SharedString, - minimum_version: &SharedString, - _window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let (heading_label, description_label) = ( - format!("Upgrade {} to work with Zed", self.agent.agent_id()), - if version.is_empty() { - format!( - "Currently using {}, which does not report a valid --version", - path, - ) - } else { - format!( - "Currently using {}, which is only version {} (need at least {minimum_version})", - path, version + store + .read(cx) + .elicitations() + .iter() + .enumerate() + .filter(|(_, elicitation)| should_render_elicitation(elicitation)) + .map(|(ix, elicitation)| { + ElicitationCard::new( + ix, + elicitation, + self.request_elicitation_form_states.get(&elicitation.id), + handlers.clone(), ) - }, - ); - - v_flex() - .w_full() - .p_3p5() - .gap_2p5() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(linear_gradient( - 180., - linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), - linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), - )) - .child( - v_flex().gap_0p5().child(Label::new(heading_label)).child( - Label::new(description_label) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() + .render(cx) + .into_any_element() + }) + .collect() } - pub(crate) fn as_native_connection( - &self, + fn request_elicitation_card_handlers(view: WeakEntity) -> ElicitationCardHandlers { + ElicitationCardHandlers::new( + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.submit_request_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.decline_request_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.cancel_request_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, url, window, cx| { + cx.open_url(&url); + view.update(cx, |this, cx| { + this.submit_request_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + this.update_request_elicitation_form_state( + &elicitation_id, + |form| form.set_boolean(&field_name, value), + cx, + ); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + this.update_request_elicitation_form_state( + &elicitation_id, + |form| form.set_single_select(&field_name, value), + cx, + ); + }) + .log_err(); + } + }, + move |elicitation_id, field_name, value, selected, cx| { + view.update(cx, |this, cx| { + this.update_request_elicitation_form_state( + &elicitation_id, + |form| form.set_multi_select(&field_name, value, selected), + cx, + ); + }) + .log_err(); + }, + ) + } + + fn update_request_elicitation_form_state( + &mut self, + elicitation_id: &ElicitationEntryId, + update: impl FnOnce(&mut ElicitationFormState), + cx: &mut Context, + ) { + if let Some(form) = self.request_elicitation_form_states.get_mut(elicitation_id) { + update(form); + self.notify_request_elicitation_renderers(cx); + } + } + + fn notify_request_elicitation_renderers(&self, cx: &mut Context) { + if let Some(active_thread) = self.active_thread().cloned() { + active_thread.update(cx, |_thread, cx| cx.notify()); + } + cx.notify(); + } + + fn submit_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(store) = self.request_elicitation_store() else { + return; + }; + + let mode = store + .read(cx) + .elicitation(&elicitation_id) + .map(|(_, elicitation)| elicitation.request.mode.clone()); + let Some(mode) = mode else { + return; + }; + + let response = match mode { + acp::ElicitationMode::Form(mode) => { + let Some(state) = self.request_elicitation_form_states.get(&elicitation_id) else { + return; + }; + match state.collect(&mode.requested_schema, cx) { + Ok(content) => { + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(content), + )) + } + Err(errors) => { + self.update_request_elicitation_form_state( + &elicitation_id, + |state| state.set_errors(errors), + cx, + ); + return; + } + } + } + acp::ElicitationMode::Url(_) => acp::CreateElicitationResponse::new( + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()), + ), + _ => return, + }; + + self.respond_to_request_elicitation(elicitation_id, response, cx); + } + + fn decline_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_request_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + } + + fn cancel_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_request_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), + cx, + ); + } + + fn respond_to_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + self.request_elicitation_form_states.remove(&elicitation_id); + if let Some(store) = self.request_elicitation_store() { + store.update(cx, |store, cx| { + store.respond_to_elicitation(&elicitation_id, response, cx); + }); + } + cx.notify(); + } + + fn cancel_request_elicitations(&mut self, cx: &mut App) { + self.request_elicitation_form_states.clear(); + if let Some(store) = self.request_elicitation_store() { + store.update(cx, |store, cx| store.clear(cx)); + } + } + + fn clear_resolved_request_elicitations(&mut self, cx: &mut App) { + if let Some(connection) = self.request_elicitation_connection() { + self.clear_resolved_request_elicitations_for_connection(&connection, cx); + } + } + + fn clear_resolved_request_elicitations_for_connection( + &mut self, + connection: &Rc, + cx: &mut App, + ) { + let Some(store) = connection.request_elicitations() else { + return; + }; + let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx)); + for id in cleared_ids { + self.request_elicitation_form_states.remove(&id); + } + } + + fn emit_load_error_telemetry(&self, error: &LoadError) { + let error_kind = match error { + LoadError::Unsupported { .. } => "unsupported", + LoadError::FailedToInstall(_) => "failed_to_install", + LoadError::Exited { .. } => "exited", + LoadError::Other(_) => "other", + }; + + let agent_name = self.agent.agent_id(); + + telemetry::event!( + "Agent Panel Error Shown", + agent = agent_name, + kind = error_kind, + message = error.to_string(), + ); + } + + fn render_load_error( + &self, + e: &LoadError, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (title, message, action_slot): (_, SharedString, _) = match e { + LoadError::Unsupported { + command: path, + current_version, + minimum_version, + } => { + return self.render_unsupported(path, current_version, minimum_version, window, cx); + } + LoadError::FailedToInstall(msg) => ( + "Failed to Install", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + LoadError::Exited { status, stderr } => { + let mut message = format!("Server exited with status {status}"); + if let Some(stderr) = stderr { + message.push_str("\n"); + message.push_str(stderr); + }; + let action_slot = stderr + .is_some() + .then(|| self.create_copy_button(message.clone()).into_any_element()); + ("Failed to Launch", message.into(), action_slot) + } + LoadError::Other(msg) => ( + "Failed to Launch", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + }; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircleFilled) + .title(title) + .description(message) + .actions_slot(div().children(action_slot)) + .into_any_element() + } + + fn render_unsupported( + &self, + path: &SharedString, + version: &SharedString, + minimum_version: &SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (heading_label, description_label) = ( + format!("Upgrade {} to work with Zed", self.agent.agent_id()), + if version.is_empty() { + format!( + "Currently using {}, which does not report a valid --version", + path, + ) + } else { + format!( + "Currently using {}, which is only version {} (need at least {minimum_version})", + path, version + ) + }, + ); + + v_flex() + .w_full() + .p_3p5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 180., + linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), + linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), + )) + .child( + v_flex().gap_0p5().child(Label::new(heading_label)).child( + Label::new(description_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + + pub(crate) fn as_native_connection( + &self, cx: &App, ) -> Option> { self.root_thread(cx)? @@ -2880,6 +3385,7 @@ impl ConversationView { match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { + window.request_attention(); if let Some(primary) = cx.primary_display() { self.pop_up( icon, @@ -2895,6 +3401,7 @@ impl ConversationView { } } NotifyWhenAgentWaiting::AllScreens => { + window.request_attention(); let caption = caption.into(); for screen in cx.displays() { self.pop_up( @@ -3163,7 +3670,7 @@ impl ConversationView { } } - fn current_model_name(&self, cx: &App) -> SharedString { + pub(crate) fn current_model_name(&self, cx: &App) -> SharedString { // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet") // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI") // This provides better clarity about what refused the request @@ -3179,6 +3686,47 @@ impl ConversationView { } } + /// The name of the currently selected model, when the agent exposes model + /// selection. Unlike [`Self::current_model_name`], never falls back to the + /// agent name. + pub(crate) fn active_model_name(&self, cx: &App) -> Option { + self.root_thread_view() + .and_then(|view| view.read(cx).active_model_name(cx)) + } + + /// The version reported by the connected agent server, when known. + pub(crate) fn agent_server_version(&self) -> Option { + self.as_connected() + .and_then(|connected| connected.connection.agent_version()) + } + + /// The root thread's primary working directory, shortened with `~` for + /// display. Falls back to `None` when the thread has no working + /// directories. + pub(crate) fn primary_work_dir_display(&self, cx: &App) -> Option { + let root_thread = self.root_thread(cx)?; + let path = root_thread + .read(cx) + .work_dirs()? + .ordered_paths() + .next()? + .clone(); + Some(SharedString::from( + path.compact().to_string_lossy().into_owned(), + )) + } + + /// The root thread's primary working directory as an absolute path. + pub(crate) fn primary_work_dir(&self, cx: &App) -> Option { + let root_thread = self.root_thread(cx)?; + root_thread + .read(cx) + .work_dirs()? + .ordered_paths() + .next() + .cloned() + } + fn create_copy_button(&self, message: impl Into) -> impl IntoElement { let message = message.into(); @@ -3186,7 +3734,7 @@ impl ConversationView { } pub fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { - let agent_id = self.agent.agent_id(); + self.cancel_request_elicitations(cx); if let Some(active) = self.root_thread_view() { active.update(cx, |active, cx| active.clear_thread_error(cx)); } @@ -3196,7 +3744,7 @@ impl ConversationView { return; }; window.defer(cx, |window, cx| { - Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); + Self::handle_auth_required(this, AuthRequired::new(), connection, window, cx); }) } @@ -3223,24 +3771,25 @@ impl ConversationView { if let Some(active) = this.root_thread_view() { active.update(cx, |active, cx| active.handle_thread_error(err, cx)); } - } else if let Some(connected) = this.as_connected_mut() { - connected.auth_state = AuthState::Unauthenticated { - description: None, - configuration_view: None, - pending_auth_method: None, - _subscription: None, - }; - cx.emit(StateChange); - if let Some(view) = connected.active_view() - && view - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) - { - this.focus_handle.focus(window, cx) + } else { + this.cancel_request_elicitations(cx); + if let Some(connected) = this.as_connected_mut() { + connected.auth_state = AuthState::Unauthenticated { + description: None, + pending_auth_method: None, + }; + cx.emit(StateChange); + if let Some(view) = connected.active_view() + && view + .read(cx) + .message_editor + .focus_handle(cx) + .is_focused(window) + { + this.focus_handle.focus(window, cx) + } + cx.notify(); } - cx.notify(); } drop(this.auth_task.take()); }) @@ -3588,73 +4137,84 @@ impl ConversationView { impl Render for ConversationView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .track_focus(&self.focus_handle) - .size_full() - .bg(cx.theme().colors().panel_background) - .child(match &self.server_state { - ServerState::Loading { - draft: Some(draft), .. - } => self.render_loading_draft(draft, cx), - ServerState::Loading { .. } => { - let label_text = self - .loading_status - .clone() - .unwrap_or_else(|| "Loading…".into()); - v_flex() - .flex_1() - .size_full() - .items_center() - .justify_center() - .child( - Label::new(label_text).color(Color::Muted).with_animation( - "loading-agent-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.7)), - |label, delta| label.alpha(delta), - ), - ) - .into_any() - } - ServerState::LoadError { error: e, .. } => v_flex() + self.sync_request_elicitation_states(window, cx); + let request_elicitation_connection = self.request_elicitation_connection(); + let active_thread_renders_request_elicitations = + self.active_thread_renders_request_elicitations(); + let content = match &self.server_state { + ServerState::Loading { + draft: Some(draft), .. + } => self.render_loading_draft(draft, cx), + ServerState::Loading { .. } => { + let label_text = self + .loading_status + .clone() + .unwrap_or_else(|| "Loading…".into()); + v_flex() .flex_1() .size_full() .items_center() - .justify_end() - .child(self.render_load_error(e, window, cx)) - .into_any(), - ServerState::Connected(ConnectedServerState { + .justify_center() + .child( + Label::new(label_text).color(Color::Muted).with_animation( + "loading-agent-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.3, 0.7)), + |label, delta| label.alpha(delta), + ), + ) + .into_any() + } + ServerState::LoadError { error: e, .. } => v_flex() + .flex_1() + .size_full() + .items_center() + .justify_end() + .child(self.render_load_error(e, window, cx)) + .into_any(), + ServerState::Connected(ConnectedServerState { + connection, + auth_state: + AuthState::Unauthenticated { + description, + pending_auth_method, + }, + .. + }) => v_flex() + .flex_1() + .size_full() + .justify_end() + .child(self.render_auth_required_state( connection, - auth_state: - AuthState::Unauthenticated { - description, - configuration_view, - pending_auth_method, - _subscription, - }, - .. - }) => v_flex() - .flex_1() - .size_full() - .justify_end() - .child(self.render_auth_required_state( - connection, - description.as_ref(), - configuration_view.as_ref(), - pending_auth_method.as_ref(), - window, - cx, - )) - .into_any_element(), - ServerState::Connected(connected) => { - if let Some(view) = connected.active_view() { - view.clone().into_any_element() - } else { - debug_panic!("This state should never be reached"); - div().into_any_element() - } + description.as_ref(), + pending_auth_method.as_ref(), + window, + cx, + )) + .into_any_element(), + ServerState::Connected(connected) => { + if let Some(view) = connected.active_view() { + view.clone().into_any_element() + } else { + debug_panic!("This state should never be reached"); + div().into_any_element() } + } + }; + + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .bg(cx.theme().colors().panel_background) + .child(v_flex().flex_1().min_h_0().child(content)) + .when(!active_thread_renders_request_elicitations, |this| { + this.children(request_elicitation_connection.as_ref().map_or_else( + Vec::new, + |connection| { + self.render_request_elicitations(connection, cx.entity().downgrade(), cx) + }, + )) }) } } @@ -3882,7 +4442,7 @@ pub(crate) mod tests { use agent_servers::FakeAcpAgentServer; use editor::MultiBufferOffset; use editor::actions::Paste; - use feature_flags::FeatureFlagAppExt as _; + use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; use fs::FakeFs; use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size}; use parking_lot::Mutex; @@ -3893,7 +4453,7 @@ pub(crate) mod tests { use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; - use workspace::{Item, MultiWorkspace}; + use workspace::{Item, MultiWorkspace, Panel as _}; use crate::agent_panel; use crate::completion_provider::AgentContextSource; @@ -3928,6 +4488,192 @@ pub(crate) mod tests { assert!(!weak_view.is_upgradable()); } + #[gpui::test] + async fn test_drop_preserves_shared_pending_request_elicitations(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let response = Arc::new(Mutex::new(None)); + let server = ReleaseRequestElicitationServer { + response: response.clone(), + }; + let (conversation_view, cx) = setup_conversation_view(server, cx).await; + let _connection = conversation_view + .read_with(cx, |view, _cx| view.request_elicitation_connection()) + .expect("conversation should have an active connection"); + let store = _connection + .request_elicitations() + .expect("connection should expose request elicitations"); + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "test should start with one pending request elicitation" + ); + }); + + assert_eq!(*response.lock(), None); + let weak_view = conversation_view.downgrade(); + drop(conversation_view); + cx.update(|_, _| {}); + cx.run_until_parked(); + + assert!(!weak_view.is_upgradable()); + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "view release should not clear connection-wide request elicitations" + ); + }); + assert_eq!(*response.lock(), None); + + store.update(cx, |store, cx| store.clear(cx)); + cx.run_until_parked(); + assert!(matches!( + response.lock().as_ref(), + Some(acp::ElicitationAction::Cancel) + )); + } + + #[gpui::test] + async fn test_state_transition_preserves_shared_pending_request_elicitations( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let response = Arc::new(Mutex::new(None)); + let server = ReleaseRequestElicitationServer { + response: response.clone(), + }; + let (conversation_view, cx) = setup_conversation_view(server, cx).await; + let connection = conversation_view + .read_with(cx, |view, _cx| view.request_elicitation_connection()) + .expect("conversation should have an active connection"); + let store = connection + .request_elicitations() + .expect("connection should expose request elicitations"); + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "test should start with one pending request elicitation" + ); + }); + + conversation_view.update(cx, |view, cx| { + view.set_server_state( + ServerState::LoadError { + error: LoadError::Other("load failed".into()), + }, + cx, + ); + }); + cx.run_until_parked(); + + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "leaving a connection should not clear connection-wide request elicitations" + ); + }); + assert_eq!(*response.lock(), None); + + store.update(cx, |store, cx| store.clear(cx)); + cx.run_until_parked(); + assert!(matches!( + response.lock().as_ref(), + Some(acp::ElicitationAction::Cancel) + )); + } + + #[gpui::test] + async fn test_successful_session_creation_clears_resolved_request_elicitations( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let response = Arc::new(Mutex::new(None)); + let server = SessionCreationRequestElicitationServer { + store: store.clone(), + response: response.clone(), + }; + let (conversation_view, cx) = setup_conversation_view(server, cx).await; + let first_request_id = acp::RequestId::Number(1); + let second_request_id = acp::RequestId::Number(2); + let first_elicitation_id = store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 2, + "session creation should be waiting on one prompt with another prompt still pending" + ); + store + .elicitations() + .iter() + .find_map(|elicitation| { + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + return None; + }; + (&scope.request_id == &first_request_id).then(|| elicitation.id.clone()) + }) + .expect("first request-scoped elicitation should exist") + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &first_elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + cx.run_until_parked(); + + assert!(matches!( + response.lock().as_ref(), + Some(acp::ElicitationAction::Accept(_)) + )); + conversation_view.read_with(cx, |view, _cx| { + let connected = view + .as_connected() + .expect("session creation should complete successfully"); + assert!( + connected.active_id.is_some(), + "successful session creation should install an active thread" + ); + }); + store.read_with(cx, |store, _cx| { + let [remaining] = store.elicitations() else { + panic!( + "expected only the pending request elicitation to remain, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = remaining.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, second_request_id); + assert!(matches!( + remaining.status, + ElicitationStatus::Pending { .. } + )); + }); + + store.update(cx, |store, cx| store.clear(cx)); + cx.run_until_parked(); + } + #[gpui::test] async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) { init_test(cx); @@ -4298,17 +5044,42 @@ pub(crate) mod tests { server.simulate_server_exit(); cx.run_until_parked(); - conversation_view.read_with(cx, |view, _cx| { + conversation_view.read_with(cx, |view, _cx| { + assert!( + matches!(view.server_state, ServerState::LoadError { .. }), + "Conversation should transition to LoadError when an ACP thread exits" + ); + }); + assert_eq!( + close_session_count.load(std::sync::atomic::Ordering::SeqCst), + 1, + "ConversationView should close the ACP session after a thread exit" + ); + } + + #[gpui::test] + async fn test_thread_view_seeds_existing_elicitation_form_state(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let connection = PreloadedElicitationConnection::default(); + let elicitation_id = connection.elicitation_id.clone(); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + + let elicitation_id = elicitation_id + .lock() + .clone() + .expect("connection should preload an elicitation"); + let active_thread = active_thread(&conversation_view, cx); + active_thread.read_with(cx, |thread, _cx| { assert!( - matches!(view.server_state, ServerState::LoadError { .. }), - "Conversation should transition to LoadError when an ACP thread exits" + thread.has_elicitation_form_state(&elicitation_id), + "pending form elicitations that predate ThreadView construction should be usable" ); }); - assert_eq!( - close_session_count.load(std::sync::atomic::Ordering::SeqCst), - 1, - "ConversationView should close the ACP session after a thread exit" - ); } #[gpui::test] @@ -4772,6 +5543,10 @@ pub(crate) mod tests { connected.active_id.is_none(), "There should be no active thread since no session was created" ); + assert!( + !view.active_thread_renders_request_elicitations(), + "request elicitations should render outside ThreadView when no thread exists" + ); assert!( connected.threads.is_empty(), "There should be no threads since no session was created" @@ -4811,6 +5586,10 @@ pub(crate) mod tests { connected.active_id.is_some(), "There should be an active thread after successful auth" ); + assert!( + view.active_thread_renders_request_elicitations(), + "request elicitations should render inside ThreadView while authenticated" + ); assert_eq!( connected.threads.len(), 1, @@ -4841,6 +5620,14 @@ pub(crate) mod tests { !view.supports_logout(), "Logout should be hidden after logout" ); + assert!( + view.active_thread().is_some(), + "The existing thread should still exist after logout" + ); + assert!( + !view.active_thread_renders_request_elicitations(), + "Unauthenticated auth UI should render request elicitations outside ThreadView" + ); }); } @@ -4886,6 +5673,272 @@ pub(crate) mod tests { ); } + fn disable_agent_notifications(cx: &mut VisualTestContext) { + cx.update(|_window, cx| { + AgentSettings::override_global( + AgentSettings { + notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, + ..AgentSettings::get_global(cx).clone() + }, + cx, + ); + }); + } + + #[gpui::test] + async fn test_root_thread_run_state_tool_call_confirmation(cx: &mut TestAppContext) { + init_test(cx); + + let tool_call_id = acp::ToolCallId::new("tool-1"); + let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label") + .kind(acp::ToolKind::Edit) + .content(vec!["hi".into()]); + let connection = + StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( + tool_call_id, + PermissionOptions::Flat(vec![acp::PermissionOption::new( + "allow", + "Allow", + acp::PermissionOptionKind::AllowOnce, + )]), + )])); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); + + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + disable_agent_notifications(cx); + + conversation_view.read_with(cx, |view, cx| { + assert_eq!(view.root_thread_run_state(cx), ThreadRunState::Idle); + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::Completed) + ); + }); + + message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); + + conversation_view.read_with(cx, |view, cx| { + assert_eq!( + view.root_thread_run_state(cx), + ThreadRunState::ParkedOnHuman(ParkedReason::ToolCallConfirmation), + "a pending tool-call authorization should park the thread on a human" + ); + assert!(view.root_thread_run_state(cx).is_parked_on_human()); + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::WaitingForConfirmation) + ); + }); + + let (conversation, session_id) = conversation_view.read_with(cx, |view, cx| { + let conversation = view + .as_connected() + .expect("conversation should be connected") + .conversation + .clone(); + let session_id = view + .active_thread() + .expect("active thread should exist") + .read(cx) + .session_id + .clone(); + (conversation, session_id) + }); + conversation.update(cx, |conversation, cx| { + conversation + .authorize_pending_tool_call(&session_id, acp::PermissionOptionKind::AllowOnce, cx) + .expect("pending tool call should be authorizable"); + }); + cx.run_until_parked(); + + conversation_view.read_with(cx, |view, cx| { + assert_eq!( + view.root_thread_run_state(cx), + ThreadRunState::Idle, + "the turn should complete after the tool call is authorized" + ); + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::Completed) + ); + }); + } + + #[gpui::test] + async fn test_root_thread_run_state_elicitation(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + disable_agent_notifications(cx); + + message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); + + conversation_view.read_with(cx, |view, cx| { + assert_eq!( + view.root_thread_run_state(cx), + ThreadRunState::Running, + "an in-progress turn without pending requests should be Running" + ); + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::Running) + ); + }); + + let thread = conversation_view.read_with(cx, |view, cx| { + view.root_thread(cx).expect("root thread should exist") + }); + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let (elicitation_id, _response_task) = thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("elicitation request should be accepted") + }); + cx.run_until_parked(); + + conversation_view.read_with(cx, |view, cx| { + assert_eq!( + view.root_thread_run_state(cx), + ThreadRunState::ParkedOnHuman(ParkedReason::Elicitation), + "a pending elicitation should park the thread on a human" + ); + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::WaitingForConfirmation) + ); + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + cx.run_until_parked(); + + conversation_view.read_with(cx, |view, cx| { + assert_eq!( + view.root_thread_run_state(cx), + ThreadRunState::Running, + "answering the elicitation should return the thread to Running" + ); + }); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + + conversation_view.read_with(cx, |view, cx| { + assert_eq!(view.root_thread_run_state(cx), ThreadRunState::Idle); + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::Completed) + ); + }); + } + + #[gpui::test] + async fn test_generating_indicator_tracks_status_transitions(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + disable_agent_notifications(cx); + + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert!(!view.generating_indicator_in_list); + assert_thread_list_item_count_matches_entries(view, cx); + }); + + message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); + + let session_id = active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert!( + view.generating_indicator_in_list, + "the generating indicator row should be spliced in while generating" + ); + assert_thread_list_item_count_matches_entries(view, cx); + view.thread.read(cx).session_id().clone() + }); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert!( + !view.generating_indicator_in_list, + "the generating indicator row should be removed once the turn ends" + ); + assert_thread_list_item_count_matches_entries(view, cx); + }); + } + + #[gpui::test] + async fn test_root_thread_display_status_dead_server(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + + conversation_view.read_with(cx, |view, cx| { + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::Completed) + ); + }); + + connection.set_server_alive(false); + + conversation_view.read_with(cx, |view, cx| { + assert!( + !view + .root_thread(cx) + .expect("root thread should exist") + .read(cx) + .server_alive() + ); + assert_eq!( + view.root_thread_display_status(cx), + Some(ui::AgentThreadStatus::Error), + "a dead agent server process should surface as a distinct error status" + ); + }); + } + #[gpui::test] async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) { init_test(cx); @@ -4977,6 +6030,19 @@ pub(crate) mod tests { let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); workspace.focus_panel::(window, cx); + // `focus_panel` activates the panel's pane item rather than its + // dock; open the dock explicitly since this test asserts on + // dock-based visibility. + let dock_position = panel.read(cx).position(window, cx); + workspace + .dock_at_position(dock_position) + .clone() + .update(cx, |dock, cx| { + if let Some(panel_ix) = dock.panel_index_for_type::() { + dock.activate_panel(panel_ix, window, cx); + } + dock.set_open(true, window, cx); + }); panel }); @@ -5237,6 +6303,14 @@ pub(crate) mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); register_test_sidebar(true, cx); + // The sidebar starts open by default (`sidebar.starts_open`); this + // test needs it closed initially so the thread is not visible. + multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.close_sidebar(window, cx); + }) + .unwrap(); + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -5333,8 +6407,21 @@ pub(crate) mod tests { let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); - // Open the dock and activate the agent panel so it's visible + // Open the dock and activate the agent panel so it's visible. + // `focus_panel` activates the panel's pane item rather than its + // dock, so open the dock explicitly since this test asserts on + // dock-based visibility. workspace.focus_panel::(window, cx); + let dock_position = panel.read(cx).position(window, cx); + workspace + .dock_at_position(dock_position) + .clone() + .update(cx, |dock, cx| { + if let Some(panel_ix) = dock.panel_index_for_type::() { + dock.activate_panel(panel_ix, window, cx); + } + dock.set_open(true, window, cx); + }); panel }); @@ -5551,11 +6638,71 @@ pub(crate) mod tests { ); } - async fn setup_conversation_view( - agent: impl AgentServer + 'static, - cx: &mut TestAppContext, - ) -> (Entity, &mut VisualTestContext) { - setup_conversation_view_with_initial_content_opt(agent, None, cx).await + async fn setup_conversation_view( + agent: impl AgentServer + 'static, + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + setup_conversation_view_with_initial_content_opt(agent, None, cx).await + } + + #[gpui::test] + async fn test_completed_plan_snapshot_keeps_list_state_in_sync(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + + message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| { + view.send(window, cx); + }); + cx.run_until_parked(); + + let session_id = active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + view.thread.read(cx).session_id().clone() + }); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new( + "Do the thing", + acp::PlanEntryPriority::Medium, + acp::PlanEntryStatus::InProgress, + )])), + cx, + ); + }); + cx.run_until_parked(); + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + }); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new( + "Do the thing", + acp::PlanEntryPriority::Medium, + acp::PlanEntryStatus::Completed, + )])), + cx, + ); + }); + cx.run_until_parked(); + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + }); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + }); } async fn setup_conversation_view_with_initial_content( @@ -5817,6 +6964,330 @@ pub(crate) mod tests { }) } + #[derive(Clone, Default)] + struct PreloadedElicitationConnection { + elicitation_id: Arc>>, + } + + impl AgentConnection for PreloadedElicitationConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("preloaded-elicitation") + } + + fn telemetry_id(&self) -> SharedString { + "preloaded-elicitation".into() + } + + fn new_session( + self: Rc, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + let session_id = acp::SessionId::new("new-session"); + let thread = build_test_thread( + self.clone(), + project, + "PreloadedElicitationConnection", + session_id.clone(), + cx, + ); + thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("preloaded elicitation should be accepted") + .detach(); + }); + let elicitation_id = thread.read_with(cx, |thread, _cx| { + thread.entries().iter().find_map(|entry| { + if let AgentThreadEntry::Elicitation(elicitation_id) = entry { + Some(elicitation_id.clone()) + } else { + None + } + }) + }); + *self.elicitation_id.lock() = elicitation_id; + Task::ready(Ok(thread)) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct SessionCreationRequestElicitationServer { + store: Entity, + response: Arc>>, + } + + impl AgentServer for SessionCreationRequestElicitationServer { + fn logo(&self) -> ui::IconName { + ui::IconName::ZedAgent + } + + fn agent_id(&self) -> AgentId { + "SessionCreationRequestElicitation".into() + } + + fn connect( + &self, + _delegate: AgentServerDelegate, + _project: Entity, + _cx: &mut App, + ) -> Task>> { + let connection = SessionCreationRequestElicitationConnection { + store: self.store.clone(), + response: self.response.clone(), + }; + Task::ready(Ok(Rc::new(connection))) + } + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct SessionCreationRequestElicitationConnection { + store: Entity, + response: Arc>>, + } + + impl AgentConnection for SessionCreationRequestElicitationConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("session-creation-request-elicitation") + } + + fn telemetry_id(&self) -> SharedString { + "session-creation-request-elicitation".into() + } + + fn new_session( + self: Rc, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + let thread = build_test_thread( + self.clone(), + project, + "SessionCreationRequestElicitationConnection", + acp::SessionId::new("session-creation-request-elicitation-session"), + cx, + ); + let first_response_task = self.store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("first request-scoped elicitation should be accepted") + }); + self.store + .update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(2)), + acp::ElicitationSchema::new().string("account", true), + ), + "Provide an account", + ), + cx, + ) + .expect("second request-scoped elicitation should be accepted") + }) + .detach(); + + let response = self.response.clone(); + cx.spawn(async move |_cx| { + let elicitation_response = first_response_task.await; + *response.lock() = Some(elicitation_response.action); + Ok(thread) + }) + } + + fn request_elicitations(&self) -> Option> { + Some(self.store.clone()) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct ReleaseRequestElicitationServer { + response: Arc>>, + } + + impl AgentServer for ReleaseRequestElicitationServer { + fn logo(&self) -> ui::IconName { + ui::IconName::ZedAgent + } + + fn agent_id(&self) -> AgentId { + "ReleaseRequestElicitation".into() + } + + fn connect( + &self, + _delegate: AgentServerDelegate, + _project: Entity, + cx: &mut App, + ) -> Task>> { + let connection = ReleaseRequestElicitationConnection { + store: cx.new(|_| ElicitationStore::default()), + response: self.response.clone(), + }; + Task::ready(Ok(Rc::new(connection))) + } + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct ReleaseRequestElicitationConnection { + store: Entity, + response: Arc>>, + } + + impl AgentConnection for ReleaseRequestElicitationConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("release-request-elicitation") + } + + fn telemetry_id(&self) -> SharedString { + "release-request-elicitation".into() + } + + fn new_session( + self: Rc, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + let thread = build_test_thread( + self.clone(), + project, + "ReleaseRequestElicitationConnection", + acp::SessionId::new("release-request-elicitation-session"), + cx, + ); + let response_task = self.store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("request-scoped elicitation should be accepted") + }); + let response = self.response.clone(); + cx.spawn(async move |_cx| { + let elicitation_response = response_task.await; + *response.lock() = Some(elicitation_response.action); + }) + .detach(); + Task::ready(Ok(thread)) + } + + fn request_elicitations(&self) -> Option> { + Some(self.store.clone()) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } + #[derive(Clone)] struct ResumeOnlyAgentConnection; @@ -5986,12 +7457,12 @@ pub(crate) mod tests { _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { - unimplemented!() + Task::ready(Err(anyhow::anyhow!( + "prompt is not supported by AuthGatedAgentConnection" + ))) } - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { - unimplemented!() - } + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} fn into_any(self: Rc) -> Rc { self @@ -6047,7 +7518,9 @@ pub(crate) mod tests { _method_id: acp::AuthMethodId, _cx: &mut App, ) -> Task> { - unimplemented!() + Task::ready(Err(anyhow::anyhow!( + "RefusalAgentConnection has no auth methods" + ))) } fn prompt( @@ -6058,9 +7531,7 @@ pub(crate) mod tests { Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal))) } - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { - unimplemented!() - } + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} fn into_any(self: Rc) -> Rc { self @@ -6209,6 +7680,13 @@ pub(crate) mod tests { }) } + fn assert_thread_list_item_count_matches_entries(view: &ThreadView, cx: &App) { + assert_eq!( + view.list_state.item_count(), + view.thread.read(cx).entries().len() + usize::from(view.generating_indicator_in_list) + ); + } + fn message_editor( conversation_view: &Entity, cx: &TestAppContext, @@ -6606,10 +8084,20 @@ pub(crate) mod tests { cx.run_until_parked(); active_thread(&conversation_view, cx).update(cx, |view, cx| { - view.scroll_to_most_recent_user_prompt(cx); + view.scroll_to_user_message_index(None, cx); let scroll_top = view.list_state.logical_scroll_top(); // Entries layout is: [User1, Assistant1, User2, Assistant2] assert_eq!(scroll_top.item_ix, 2); + + view.scroll_to_top(cx); + view.scroll_to_user_message_index(Some(0), cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 0); + + view.scroll_to_top(cx); + view.scroll_to_user_message_index(Some(2), cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 2); }); } @@ -6624,7 +8112,7 @@ pub(crate) mod tests { // With no entries, scrolling should be a no-op and must not panic. active_thread(&conversation_view, cx).update(cx, |view, cx| { - view.scroll_to_most_recent_user_prompt(cx); + view.scroll_to_user_message_index(None, cx); let scroll_top = view.list_state.logical_scroll_top(); assert_eq!(scroll_top.item_ix, 0); }); diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs new file mode 100644 index 00000000000000..3eaaa0652bd8ab --- /dev/null +++ b/crates/agent_ui/src/conversation_view/elicitation.rs @@ -0,0 +1,1715 @@ +use acp_thread::{Elicitation, ElicitationEntryId, ElicitationStatus}; +use agent_client_protocol::schema::v1 as acp; +use collections::{HashMap, HashSet}; +use component::{Component, ComponentScope, example_group_with_title, single_example}; +use editor::Editor; +use futures::channel::oneshot; +use gpui::{AnyElement, App, Div, Empty, Entity, Hsla, SharedString, Window, div}; +use std::collections::BTreeMap; +use std::rc::Rc; +use ui::{ + Button, Checkbox, Color, Icon, IconName, IconSize, Indicator, Label, LabelSize, ToggleState, + prelude::*, +}; + +#[derive(Clone)] +struct ElicitationOption { + value: String, + label: SharedString, + description: Option, +} + +enum ElicitationFieldState { + Text(Entity), + Boolean(bool), + SingleSelect { value: Option }, + MultiSelect(HashSet), +} + +pub(crate) struct ElicitationFormState { + fields: HashMap, + field_errors: HashMap, +} + +impl ElicitationFormState { + pub(crate) fn new(schema: &acp::ElicitationSchema, window: &mut Window, cx: &mut App) -> Self { + let required = schema.required.as_deref().unwrap_or_default(); + let mut fields = HashMap::default(); + + for (name, property) in &schema.properties { + let is_required = required.iter().any(|required| required == name); + let field = match property { + acp::ElicitationPropertySchema::String(schema) => { + let options = single_select_options(schema); + if options.is_empty() { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(default) = &schema.default { + editor.set_text(default.clone(), window, cx); + } + editor + }); + ElicitationFieldState::Text(editor) + } else { + let value = single_select_default_value(schema, &options).or_else(|| { + is_required + .then(|| options.first().map(|option| option.value.clone())) + .flatten() + }); + ElicitationFieldState::SingleSelect { value } + } + } + acp::ElicitationPropertySchema::Number(schema) => { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(default) = schema.default { + editor.set_text(default.to_string(), window, cx); + } + editor + }); + ElicitationFieldState::Text(editor) + } + acp::ElicitationPropertySchema::Integer(schema) => { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(default) = schema.default { + editor.set_text(default.to_string(), window, cx); + } + editor + }); + ElicitationFieldState::Text(editor) + } + acp::ElicitationPropertySchema::Boolean(schema) => { + ElicitationFieldState::Boolean(schema.default.unwrap_or(false)) + } + acp::ElicitationPropertySchema::Array(schema) => { + ElicitationFieldState::MultiSelect( + schema + .default + .clone() + .unwrap_or_default() + .into_iter() + .collect(), + ) + } + _ => continue, + }; + fields.insert(name.clone(), field); + } + + Self { + fields, + field_errors: HashMap::default(), + } + } + + pub(crate) fn collect( + &self, + schema: &acp::ElicitationSchema, + cx: &App, + ) -> Result, HashMap> { + let required = schema.required.as_deref().unwrap_or_default(); + let mut content = BTreeMap::new(); + let mut errors = HashMap::default(); + + for (name, property) in &schema.properties { + let is_required = required.iter().any(|required| required == name); + let Some(field) = self.fields.get(name) else { + continue; + }; + + let field_content = match (property, field) { + ( + acp::ElicitationPropertySchema::String(schema), + ElicitationFieldState::Text(editor), + ) => { + let value = editor.read(cx).text(cx).to_string(); + if value.is_empty() { + if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } else { + validate_string_value(property_title(name, property), schema, &value) + .map(|()| Some(value.into())) + } + } + ( + acp::ElicitationPropertySchema::String(schema), + ElicitationFieldState::SingleSelect { value }, + ) => { + if let Some(value) = value { + validate_single_select_value(property_title(name, property), schema, value) + .and_then(|()| { + validate_string_value(property_title(name, property), schema, value) + }) + .map(|()| Some(value.clone().into())) + } else if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } + ( + acp::ElicitationPropertySchema::Number(schema), + ElicitationFieldState::Text(editor), + ) => { + let value = editor.read(cx).text(cx).trim().to_string(); + if value.is_empty() { + if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } else { + validate_number_value(property_title(name, property), schema, &value) + .map(|parsed| Some(parsed.into())) + } + } + ( + acp::ElicitationPropertySchema::Integer(schema), + ElicitationFieldState::Text(editor), + ) => { + let value = editor.read(cx).text(cx).trim().to_string(); + if value.is_empty() { + if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } else { + validate_integer_value(property_title(name, property), schema, &value) + .map(|parsed| Some(parsed.into())) + } + } + ( + acp::ElicitationPropertySchema::Boolean(schema), + ElicitationFieldState::Boolean(value), + ) => { + if is_required || *value || schema.default.is_some() { + Ok(Some((*value).into())) + } else { + Ok(None) + } + } + ( + acp::ElicitationPropertySchema::Array(schema), + ElicitationFieldState::MultiSelect(selected), + ) => { + let mut values = multi_select_options(schema) + .into_iter() + .filter_map(|option| { + selected.contains(&option.value).then_some(option.value) + }) + .collect::>(); + values.sort(); + if values.is_empty() && !is_required { + Ok(None) + } else if schema + .min_items + .is_some_and(|min_items| values.len() < min_items as usize) + { + Err( + format!("{} needs more selections", property_title(name, property)) + .into(), + ) + } else if schema + .max_items + .is_some_and(|max_items| values.len() > max_items as usize) + { + Err( + format!("{} has too many selections", property_title(name, property)) + .into(), + ) + } else { + Ok(Some(values.into())) + } + } + _ => Ok(None), + }; + + match field_content { + Ok(Some(value)) => { + content.insert(name.clone(), value); + } + Ok(None) => {} + Err(error) => { + errors.insert(name.clone(), error); + } + } + } + + if errors.is_empty() { + Ok(content) + } else { + Err(errors) + } + } + + pub(crate) fn set_errors(&mut self, errors: HashMap) { + self.field_errors = errors; + } + + pub(crate) fn set_field_error( + &mut self, + field_name: impl Into, + error: impl Into, + ) { + self.field_errors.insert(field_name.into(), error.into()); + } + + pub(crate) fn set_boolean(&mut self, field_name: &str, value: bool) { + if let Some(ElicitationFieldState::Boolean(field)) = self.fields.get_mut(field_name) { + *field = value; + self.field_errors.remove(field_name); + } + } + + pub(crate) fn set_single_select(&mut self, field_name: &str, value: String) { + if let Some(ElicitationFieldState::SingleSelect { value: selected }) = + self.fields.get_mut(field_name) + { + *selected = Some(value); + self.field_errors.remove(field_name); + } + } + + pub(crate) fn set_multi_select(&mut self, field_name: &str, value: String, selected: bool) { + if let Some(ElicitationFieldState::MultiSelect(values)) = self.fields.get_mut(field_name) { + if selected { + values.insert(value); + } else { + values.remove(&value); + } + self.field_errors.remove(field_name); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[test] + fn string_validation_rejects_email_format_mismatch() { + let schema = acp::StringPropertySchema::email(); + + validate_string_value("Email".into(), &schema, "user@example.com") + .expect("valid email should be accepted"); + assert_eq!( + validate_string_value("Email".into(), &schema, "not-an-email") + .expect_err("invalid email should be rejected") + .to_string(), + "Email must be an email address" + ); + } + + #[test] + fn string_validation_rejects_pattern_mismatch() { + let schema = acp::StringPropertySchema::new().pattern("^prod-[0-9]+$"); + + validate_string_value("Environment".into(), &schema, "prod-42") + .expect("matching pattern should be accepted"); + assert_eq!( + validate_string_value("Environment".into(), &schema, "dev-42") + .expect_err("pattern mismatch should be rejected") + .to_string(), + "Environment does not match the requested pattern" + ); + } + + #[test] + fn number_validation_rejects_non_finite_values() { + let schema = acp::NumberPropertySchema::new(); + + assert_eq!( + validate_number_value("Amount".into(), &schema, "42.5") + .expect("finite number should be accepted"), + 42.5 + ); + + for value in ["NaN", "inf", "-inf", "1e309"] { + assert_eq!( + validate_number_value("Amount".into(), &schema, value) + .expect_err("non-finite number should be rejected") + .to_string(), + "Amount must be a finite number" + ); + } + } + + #[test] + fn should_render_pending_and_accepted_url_elicitations() { + let pending = Elicitation { + id: ElicitationEntryId("pending".into()), + request: acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + preview_request_scope(0), + acp::ElicitationSchema::new(), + ), + "Review this request.", + ), + status: pending_status(), + }; + assert!(should_render_elicitation(&pending)); + + let accepted_url = Elicitation { + id: ElicitationEntryId("accepted-url".into()), + request: acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + preview_request_scope(1), + acp::ElicitationId::new("accepted-url"), + "https://auth.example.com/device", + ), + "Authorize Zed in your browser.", + ), + status: ElicitationStatus::Accepted, + }; + assert!(should_render_elicitation(&accepted_url)); + + let accepted_form = Elicitation { + id: ElicitationEntryId("accepted-form".into()), + request: acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + preview_request_scope(2), + acp::ElicitationSchema::new(), + ), + "Review this request.", + ), + status: ElicitationStatus::Accepted, + }; + assert!(!should_render_elicitation(&accepted_form)); + } + + #[test] + fn display_url_segments_never_elide_url_characters() { + let url = format!( + "https://auth.example.com/oauth/authorize?state={}", + "a".repeat(MAX_URL_DISPLAY_SEGMENT_CHARS * 2) + ); + + let display_url_segments = display_url_segments(&url) + .into_iter() + .map(|segment| segment.to_string()) + .collect::>(); + assert!(display_url_segments.len() > 1); + assert!( + !display_url_segments + .iter() + .any(|segment| segment.contains('…')) + ); + assert!( + display_url_segments + .iter() + .all(|segment| segment.chars().count() <= MAX_URL_DISPLAY_SEGMENT_CHARS) + ); + assert_eq!(display_url_segments.concat(), url); + } + + #[test] + fn display_url_segments_use_larger_url_boundaries() { + let url = "https://auth.example.com/oauth/authorize?client_id=zed-desktop&scope=repository"; + + let display_url_segments = display_url_segments(url) + .into_iter() + .map(|segment| segment.to_string()) + .collect::>(); + assert_eq!(display_url_segments.concat(), url); + assert_eq!(display_url_segments[0], "https://auth.example.com/"); + assert!( + display_url_segments + .iter() + .any(|segment| segment.ends_with('?')) + ); + assert!( + display_url_segments + .iter() + .any(|segment| segment.ends_with('&')) + ); + } + + #[test] + fn single_select_options_include_titled_descriptions() { + let schema = acp::StringPropertySchema::new().one_of(vec![ + acp::EnumOption::new("production", "Production").description("Use live resources"), + ]); + + let options = single_select_options(&schema); + + let [option] = options.as_slice() else { + panic!("expected one option, got {}", options.len()); + }; + assert_eq!(option.value, "production"); + assert_eq!(option.label.to_string(), "Production"); + assert_eq!( + option + .description + .as_ref() + .map(|description| description.to_string()), + Some("Use live resources".to_string()) + ); + } + + #[test] + fn multi_select_options_include_titled_descriptions() { + let schema = acp::MultiSelectPropertySchema::titled(vec![ + acp::EnumOption::new("repository", "Repository Access") + .description("Read and update repositories"), + ]); + + let options = multi_select_options(&schema); + + let [option] = options.as_slice() else { + panic!("expected one option, got {}", options.len()); + }; + assert_eq!(option.value, "repository"); + assert_eq!(option.label.to_string(), "Repository Access"); + assert_eq!( + option + .description + .as_ref() + .map(|description| description.to_string()), + Some("Read and update repositories".to_string()) + ); + } + + #[gpui::test] + fn form_state_preserves_string_whitespace(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "token", + acp::StringPropertySchema::new() + .title("Token") + .default_value(" secret "), + true, + ); + let form_state = ElicitationFormState::new(&schema, window, cx); + let content = form_state + .collect(&schema, cx) + .expect("string with whitespace should be submitted"); + + assert_eq!( + content.get("token"), + Some(&acp::ElicitationContentValue::String( + " secret ".to_string() + )) + ); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_discards_invalid_optional_single_select_default(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]) + .default_value("development"), + false, + ); + let form_state = ElicitationFormState::new(&schema, window, cx); + let content = form_state + .collect(&schema, cx) + .expect("invalid optional default should be ignored"); + + assert_eq!(content.get("environment"), None); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_replaces_invalid_required_single_select_default(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]) + .default_value("development"), + true, + ); + let form_state = ElicitationFormState::new(&schema, window, cx); + let content = form_state + .collect(&schema, cx) + .expect("required select should use the first valid choice"); + + assert_eq!( + content.get("environment"), + Some(&acp::ElicitationContentValue::String( + "production".to_string() + )) + ); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_rejects_invalid_single_select_value(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]), + false, + ); + let mut form_state = ElicitationFormState::new(&schema, window, cx); + form_state.set_single_select("environment", "development".to_string()); + + let errors = form_state + .collect(&schema, cx) + .expect_err("invalid selected value should be rejected"); + assert_eq!( + errors + .get("environment") + .expect("environment should have an error") + .to_string(), + "Environment must be one of the provided options" + ); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_reports_all_validation_errors(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new() + .string("account", true) + .property( + "age", + acp::IntegerPropertySchema::new().title("Age").minimum(18), + true, + ) + .property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]), + false, + ); + let mut form_state = ElicitationFormState::new(&schema, window, cx); + if let Some(ElicitationFieldState::Text(editor)) = form_state.fields.get("age") { + editor.update(cx, |editor, cx| editor.set_text("abc", window, cx)); + } + form_state.set_single_select("environment", "development".to_string()); + + let errors = form_state + .collect(&schema, cx) + .expect_err("all invalid fields should be reported"); + assert_eq!( + errors + .get("account") + .expect("account should have an error") + .to_string(), + "account is required" + ); + assert_eq!( + errors + .get("age") + .expect("age should have an error") + .to_string(), + "Age must be an integer" + ); + assert_eq!( + errors + .get("environment") + .expect("environment should have an error") + .to_string(), + "Environment must be one of the provided options" + ); + + Editor::single_line(window, cx) + }); + } +} + +#[derive(RegisterComponent)] +pub struct ElicitationCardPreview; + +impl Component for ElicitationCardPreview { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn description() -> &'static str { + "ACP elicitation request cards as rendered in the agent panel." + } + + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children([ + example_group_with_title( + "Form Requests", + vec![ + single_example( + "Pending Form", + render_form_preview(0, pending_status(), &[], window, cx), + ) + .width(px(640.)), + single_example( + "Validation Errors", + render_form_preview( + 1, + pending_status(), + &[ + ("account_name", "Account name is required"), + ("environment", "Choose an environment"), + ("scopes", "Choose at least one access scope"), + ], + window, + cx, + ), + ) + .width(px(640.)), + ], + ) + .vertical() + .into_any_element(), + example_group_with_title( + "URL Requests", + vec![ + single_example( + "URL Consent", + render_url_preview(3, pending_status(), window, cx), + ) + .width(px(640.)), + ], + ) + .vertical() + .into_any_element(), + example_group_with_title( + "Terminal States", + vec![ + single_example( + "Declined", + render_form_preview(6, ElicitationStatus::Declined, &[], window, cx), + ) + .width(px(640.)), + single_example( + "Canceled", + render_form_preview(7, ElicitationStatus::Canceled, &[], window, cx), + ) + .width(px(640.)), + ], + ) + .vertical() + .into_any_element(), + ]) + .into_any_element() + } +} + +fn render_form_preview( + entry_ix: usize, + status: ElicitationStatus, + field_errors: &[(&'static str, &'static str)], + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let request = acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new(preview_request_scope(entry_ix), preview_form_schema()), + "Choose how Zed should connect to this account.", + ); + let mut form_state = matches!(status, ElicitationStatus::Pending { .. }).then(|| { + let acp::ElicitationMode::Form(mode) = &request.mode else { + unreachable!(); + }; + ElicitationFormState::new(&mode.requested_schema, window, cx) + }); + if let Some(form_state) = &mut form_state { + for (field_name, error) in field_errors { + form_state.set_field_error(*field_name, *error); + } + } + + render_preview_card(entry_ix, request, status, form_state.as_ref(), cx) +} + +fn preview_url() -> &'static str { + "https://auth.example.com/oauth/authorize?client_id=zed-desktop&redirect_uri=zed%3A%2F%2Fagent%2Facp%2Fcallback&scope=profile%20repository%20terminal&state=9b8b0a873a1e4b57b7f9f7b6d2d3d0f4" +} + +fn render_url_preview( + entry_ix: usize, + status: ElicitationStatus, + _window: &mut Window, + cx: &mut App, +) -> AnyElement { + let request = acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + preview_request_scope(entry_ix), + acp::ElicitationId::new(format!("preview-url-{entry_ix}")), + preview_url(), + ), + "Authorize Zed in your browser to finish signing in.", + ); + + render_preview_card(entry_ix, request, status, None, cx) +} + +fn render_preview_card( + entry_ix: usize, + request: acp::CreateElicitationRequest, + status: ElicitationStatus, + form_state: Option<&ElicitationFormState>, + cx: &App, +) -> AnyElement { + let elicitation = Elicitation { + id: ElicitationEntryId(format!("preview-elicitation-{entry_ix}").into()), + request, + status, + }; + + div() + .w_full() + .max_w(px(640.)) + .child( + ElicitationCard::new( + entry_ix, + &elicitation, + form_state, + ElicitationCardHandlers::noop(), + ) + .render(cx), + ) + .into_any_element() +} + +fn pending_status() -> ElicitationStatus { + let (respond_tx, _response_rx) = oneshot::channel(); + ElicitationStatus::Pending { respond_tx } +} + +fn preview_request_scope(index: usize) -> acp::ElicitationRequestScope { + acp::ElicitationRequestScope::new(acp::RequestId::Number(index as i64)) +} + +fn preview_form_schema() -> acp::ElicitationSchema { + acp::ElicitationSchema::new() + .property( + "account_name", + acp::StringPropertySchema::new() + .title("Account Name") + .description("Used to label this connection in the agent panel.") + .default_value("Work"), + true, + ) + .property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .description("Select the environment this credential should target.") + .one_of(vec![ + acp::EnumOption::new("production", "Production") + .description("Use the live account and production resources."), + acp::EnumOption::new("staging", "Staging") + .description("Validate changes against staging data first."), + acp::EnumOption::new("development", "Development"), + ]) + .default_value("staging"), + true, + ) + .property( + "scopes", + acp::MultiSelectPropertySchema::titled(vec![ + acp::EnumOption::new("profile", "Profile") + .description("Read account identity and basic profile details."), + acp::EnumOption::new("repository", "Repository Access") + .description("Read and update repositories connected to this account."), + acp::EnumOption::new("terminal", "Terminal Commands"), + ]) + .title("Access") + .description("Choose what the agent can use for this authorization.") + .min_items(1) + .default_value(vec!["profile".to_string(), "repository".to_string()]), + true, + ) + .property( + "remember", + acp::BooleanPropertySchema::new() + .title("Remember Authorization") + .description("Store this authorization for future sessions.") + .default_value(true), + false, + ) +} + +fn single_select_options(schema: &acp::StringPropertySchema) -> Vec { + if let Some(options) = &schema.one_of { + return options + .iter() + .map(|option| ElicitationOption { + value: option.value.clone(), + label: SharedString::from(option.title.clone()), + description: option.description.clone().map(SharedString::from), + }) + .collect(); + } + + schema + .enum_values + .as_deref() + .unwrap_or_default() + .iter() + .map(|value| ElicitationOption { + value: value.clone(), + label: SharedString::from(value.clone()), + description: None, + }) + .collect() +} + +fn single_select_default_value( + schema: &acp::StringPropertySchema, + options: &[ElicitationOption], +) -> Option { + schema + .default + .as_ref() + .filter(|default| { + options + .iter() + .any(|option| option.value.as_str() == default.as_str()) + }) + .cloned() +} + +fn multi_select_options(schema: &acp::MultiSelectPropertySchema) -> Vec { + match &schema.items { + acp::MultiSelectItems::String(items) => items + .values + .iter() + .map(|value| ElicitationOption { + value: value.clone(), + label: SharedString::from(value.clone()), + description: None, + }) + .collect(), + acp::MultiSelectItems::Titled(items) => items + .options + .iter() + .map(|option| ElicitationOption { + value: option.value.clone(), + label: SharedString::from(option.title.clone()), + description: option.description.clone().map(SharedString::from), + }) + .collect(), + _ => Vec::new(), + } +} + +fn validate_number_value( + title: SharedString, + schema: &acp::NumberPropertySchema, + value: &str, +) -> Result { + let parsed = value + .parse::() + .map_err(|_| SharedString::from(format!("{title} must be a number")))?; + if !parsed.is_finite() { + return Err(format!("{title} must be a finite number").into()); + } + if let Some(minimum) = schema.minimum + && parsed < minimum + { + return Err(format!("{title} must be at least {minimum}").into()); + } + if let Some(maximum) = schema.maximum + && parsed > maximum + { + return Err(format!("{title} must be at most {maximum}").into()); + } + + Ok(parsed) +} + +fn validate_integer_value( + title: SharedString, + schema: &acp::IntegerPropertySchema, + value: &str, +) -> Result { + let parsed = value + .parse::() + .map_err(|_| SharedString::from(format!("{title} must be an integer")))?; + if let Some(minimum) = schema.minimum + && parsed < minimum + { + return Err(format!("{title} must be at least {minimum}").into()); + } + if let Some(maximum) = schema.maximum + && parsed > maximum + { + return Err(format!("{title} must be at most {maximum}").into()); + } + + Ok(parsed) +} + +fn validate_string_value( + title: SharedString, + schema: &acp::StringPropertySchema, + value: &str, +) -> Result<(), SharedString> { + let length = value.chars().count(); + if schema + .min_length + .is_some_and(|min_length| length < min_length as usize) + { + return Err(format!("{title} is too short").into()); + } + if schema + .max_length + .is_some_and(|max_length| length > max_length as usize) + { + return Err(format!("{title} is too long").into()); + } + + validate_string_pattern_and_format(title, schema, value) +} + +fn validate_single_select_value( + title: SharedString, + schema: &acp::StringPropertySchema, + value: &str, +) -> Result<(), SharedString> { + let options = single_select_options(schema); + if options.iter().any(|option| option.value.as_str() == value) { + Ok(()) + } else { + Err(format!("{title} must be one of the provided options").into()) + } +} + +fn validate_string_pattern_and_format( + title: SharedString, + schema: &acp::StringPropertySchema, + value: &str, +) -> Result<(), SharedString> { + if schema.pattern.is_none() && schema.format.and_then(string_format_json_name).is_none() { + return Ok(()); + } + + let mut validation_schema = serde_json::Map::new(); + validation_schema.insert( + "type".to_string(), + serde_json::Value::String("string".into()), + ); + if let Some(pattern) = &schema.pattern { + validation_schema.insert( + "pattern".to_string(), + serde_json::Value::String(pattern.clone()), + ); + } + if let Some(format) = schema.format.and_then(string_format_json_name) { + validation_schema.insert( + "format".to_string(), + serde_json::Value::String(format.into()), + ); + } + + let validation_schema = serde_json::Value::Object(validation_schema); + let validator = jsonschema::options() + .should_validate_formats(true) + .build(&validation_schema) + .map_err(|_| { + if schema.pattern.is_some() { + format!("{title} has an invalid validation pattern") + } else { + format!("{title} has an invalid validation format") + } + })?; + if validator.is_valid(&serde_json::Value::String(value.to_string())) { + return Ok(()); + } + + match ( + schema.pattern.is_some(), + schema.format.and_then(string_format_label), + ) { + (true, Some(_)) => Err(format!("{title} does not match the requested constraints").into()), + (true, None) => Err(format!("{title} does not match the requested pattern").into()), + (false, Some(format)) => Err(format!("{title} must be {format}").into()), + (false, None) => Ok(()), + } +} + +fn string_format_json_name(format: acp::StringFormat) -> Option<&'static str> { + match format { + acp::StringFormat::Email => Some("email"), + acp::StringFormat::Uri => Some("uri"), + acp::StringFormat::Date => Some("date"), + acp::StringFormat::DateTime => Some("date-time"), + _ => None, + } +} + +fn string_format_label(format: acp::StringFormat) -> Option<&'static str> { + match format { + acp::StringFormat::Email => Some("an email address"), + acp::StringFormat::Uri => Some("a URI"), + acp::StringFormat::Date => Some("a date"), + acp::StringFormat::DateTime => Some("a date and time"), + _ => None, + } +} + +fn property_title(name: &str, property: &acp::ElicitationPropertySchema) -> SharedString { + let title = match property { + acp::ElicitationPropertySchema::String(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Number(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Integer(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Boolean(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Array(schema) => schema.title.as_deref(), + _ => None, + }; + SharedString::from(title.unwrap_or(name).to_string()) +} + +fn property_description(property: &acp::ElicitationPropertySchema) -> Option { + match property { + acp::ElicitationPropertySchema::String(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Number(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Integer(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Boolean(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Array(schema) => schema.description.clone(), + _ => None, + } + .map(SharedString::from) +} + +type RespondHandler = Rc; +type OpenUrlHandler = Rc; +type BooleanHandler = Rc; +type SelectHandler = Rc; +type MultiSelectHandler = Rc; + +#[derive(Clone)] +pub(crate) struct ElicitationCardHandlers { + on_submit: RespondHandler, + on_decline: RespondHandler, + on_cancel: RespondHandler, + on_open_url: OpenUrlHandler, + on_boolean_change: BooleanHandler, + on_single_select_change: SelectHandler, + on_multi_select_change: MultiSelectHandler, +} + +impl ElicitationCardHandlers { + pub(crate) fn new( + on_submit: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static, + on_decline: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static, + on_cancel: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static, + on_open_url: impl Fn(ElicitationEntryId, String, &mut Window, &mut App) + 'static, + on_boolean_change: impl Fn(ElicitationEntryId, String, bool, &mut App) + 'static, + on_single_select_change: impl Fn(ElicitationEntryId, String, String, &mut App) + 'static, + on_multi_select_change: impl Fn(ElicitationEntryId, String, String, bool, &mut App) + 'static, + ) -> Self { + Self { + on_submit: Rc::new(on_submit), + on_decline: Rc::new(on_decline), + on_cancel: Rc::new(on_cancel), + on_open_url: Rc::new(on_open_url), + on_boolean_change: Rc::new(on_boolean_change), + on_single_select_change: Rc::new(on_single_select_change), + on_multi_select_change: Rc::new(on_multi_select_change), + } + } + + pub(crate) fn noop() -> Self { + Self::new( + |_, _, _| {}, + |_, _, _| {}, + |_, _, _| {}, + |_, _, _, _| {}, + |_, _, _, _| {}, + |_, _, _, _| {}, + |_, _, _, _, _| {}, + ) + } +} + +pub(crate) fn should_render_elicitation(elicitation: &Elicitation) -> bool { + matches!( + (&elicitation.status, &elicitation.request.mode), + (ElicitationStatus::Pending { .. }, _) + | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_)) + ) +} + +const MIN_URL_DISPLAY_SEGMENT_CHARS: usize = 16; +const MAX_URL_DISPLAY_SEGMENT_CHARS: usize = 64; + +fn display_url_segments(url: &str) -> Vec { + let mut segments = Vec::new(); + let mut segment = String::new(); + let mut segment_chars = 0; + let mut characters = url.chars().peekable(); + + while let Some(character) = characters.next() { + segment.push(character); + segment_chars += 1; + + let should_split_at_boundary = segment_chars >= MIN_URL_DISPLAY_SEGMENT_CHARS + && is_url_display_segment_boundary(character); + let should_split_at_length = segment_chars >= MAX_URL_DISPLAY_SEGMENT_CHARS; + + if characters.peek().is_some() && (should_split_at_boundary || should_split_at_length) { + segments.push(std::mem::take(&mut segment).into()); + segment_chars = 0; + } + } + + if !segment.is_empty() { + segments.push(segment.into()); + } + + segments +} + +fn is_url_display_segment_boundary(character: char) -> bool { + matches!(character, '/' | '?' | '&' | '#') +} + +pub(crate) struct ElicitationCard<'a> { + entry_ix: usize, + elicitation: &'a Elicitation, + form_state: Option<&'a ElicitationFormState>, + handlers: ElicitationCardHandlers, +} + +impl<'a> ElicitationCard<'a> { + pub(crate) fn new( + entry_ix: usize, + elicitation: &'a Elicitation, + form_state: Option<&'a ElicitationFormState>, + handlers: ElicitationCardHandlers, + ) -> Self { + Self { + entry_ix, + elicitation, + form_state, + handlers, + } + } + + pub(crate) fn render(self, cx: &App) -> Div { + let border_color = cx.theme().colors().border.opacity(0.8); + let header_background = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)); + let tool_name_font_size = rems_from_px(13.); + let is_pending = matches!(&self.elicitation.status, ElicitationStatus::Pending { .. }); + let is_accepted_url = matches!( + (&self.elicitation.status, &self.elicitation.request.mode), + (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_)) + ); + let (status_label, status_icon, status_color) = match &self.elicitation.status { + ElicitationStatus::Pending { .. } => ("Waiting for input", IconName::Info, Color::Info), + ElicitationStatus::Accepted if is_accepted_url => { + ("Waiting for completion", IconName::Info, Color::Info) + } + ElicitationStatus::Accepted => ("Submitted", IconName::Check, Color::Success), + ElicitationStatus::Declined => ("Declined", IconName::Close, Color::Muted), + ElicitationStatus::Canceled => ("Canceled", IconName::Circle, Color::Muted), + ElicitationStatus::Completed => ("Completed", IconName::Check, Color::Success), + }; + + let body = v_flex() + .gap_2() + .p_3() + .child(Label::new(self.elicitation.request.message.clone()).size(LabelSize::Small)); + let body = match &self.elicitation.request.mode { + acp::ElicitationMode::Form(mode) if is_pending => { + body.child(self.render_form(mode, cx)) + } + acp::ElicitationMode::Url(mode) if is_pending || is_accepted_url => { + body.child(self.render_url_elicitation(mode)) + } + _ => body, + }; + + v_flex() + .mx_5() + .my_1p5() + .rounded_md() + .border_1() + .border_color(border_color) + .overflow_hidden() + .child( + h_flex() + .h_8() + .p_1() + .w_full() + .justify_between() + .bg(header_background) + .child( + h_flex() + .min_w_0() + .gap_1p5() + .px_1() + .child( + Icon::new(status_icon) + .size(IconSize::Small) + .color(status_color), + ) + .child( + Label::new("Input Requested") + .size(LabelSize::Custom(tool_name_font_size)) + .truncate(), + ), + ) + .child( + Label::new(status_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child(body) + .when(is_pending, |this| this.child(self.render_actions(cx))) + } + + fn render_form(&self, mode: &acp::ElicitationFormMode, cx: &App) -> AnyElement { + let Some(state) = self.form_state else { + return Empty.into_any_element(); + }; + + v_flex() + .gap_2() + .children(mode.requested_schema.properties.iter().filter_map( + |(field_name, property)| { + let field = state.fields.get(field_name)?; + Some(self.render_field( + field_name, + property, + field, + state.field_errors.get(field_name), + cx, + )) + }, + )) + .into_any_element() + } + + fn render_field( + &self, + field_name: &str, + property: &acp::ElicitationPropertySchema, + field: &ElicitationFieldState, + error: Option<&SharedString>, + cx: &App, + ) -> AnyElement { + let label = property_title(field_name, property); + let description = property_description(property); + let border_color = cx.theme().colors().border.opacity(0.8); + let field_border_color = if error.is_some() { + Color::Error.color(cx) + } else { + border_color + }; + let editor_background = cx.theme().colors().editor_background; + let label_color = if error.is_some() { + Color::Error + } else { + Color::Default + }; + + if let ElicitationFieldState::Boolean(value) = field { + let checkbox_state = if *value { + ToggleState::Selected + } else { + ToggleState::Unselected + }; + let next_value = !*value; + let on_boolean_change = self.handlers.on_boolean_change.clone(); + let elicitation_id = self.elicitation.id.clone(); + let field_name = field_name.to_string(); + let row_id = format!("elicitation-bool-row-{}-{field_name}", self.entry_ix); + let checkbox_id = format!("elicitation-bool-{}-{field_name}", self.entry_ix); + + return v_flex() + .gap_1() + .child( + h_flex() + .id(row_id) + .w_full() + .items_start() + .gap_1() + .cursor_pointer() + .on_click(move |_, _window, cx| { + on_boolean_change( + elicitation_id.clone(), + field_name.clone(), + next_value, + cx, + ); + }) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) + .child( + v_flex() + .gap_0p5() + .child(Label::new(label).size(LabelSize::Small).color(label_color)) + .when_some(description, |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ), + ) + .when_some(error.cloned(), |this, error| { + this.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .into_any_element(); + } + + let label = if error.is_some() { + Label::new(label).size(LabelSize::Small).color(Color::Error) + } else { + Label::new(label).size(LabelSize::Small) + }; + + v_flex() + .gap_1() + .child(label) + .when_some(description, |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .child(match field { + ElicitationFieldState::Text(editor) => div() + .rounded_sm() + .border_1() + .border_color(field_border_color) + .bg(editor_background) + .px_1() + .py_0p5() + .text_xs() + .child(editor.clone().into_any_element()) + .into_any_element(), + ElicitationFieldState::Boolean(_) => Empty.into_any_element(), + ElicitationFieldState::SingleSelect { value } => { + let options = match property { + acp::ElicitationPropertySchema::String(schema) => { + single_select_options(schema) + } + _ => Vec::new(), + }; + self.render_single_select( + field_name, + value.as_ref(), + options, + error.is_some(), + cx, + ) + } + ElicitationFieldState::MultiSelect(selected) => { + let options = match property { + acp::ElicitationPropertySchema::Array(schema) => { + multi_select_options(schema) + } + _ => Vec::new(), + }; + v_flex() + .gap_1() + .children(options.into_iter().map(|option| { + let is_selected = selected.contains(&option.value); + let checkbox_state = if is_selected { + ToggleState::Selected + } else { + ToggleState::Unselected + }; + let row_background = Self::option_row_background(is_selected, cx); + let hover_background = + Self::option_row_hover_background(is_selected, cx); + let on_multi_select_change = + self.handlers.on_multi_select_change.clone(); + let elicitation_id = self.elicitation.id.clone(); + let field_name = field_name.to_string(); + let value = option.value.clone(); + let checkbox_id = format!( + "elicitation-multi-{}-{field_name}-{}", + self.entry_ix, option.value + ); + h_flex() + .id(SharedString::from(format!( + "elicitation-multi-option-{}-{field_name}-{}", + self.entry_ix, option.value + ))) + .w_full() + .min_h(rems_from_px(28.)) + .items_start() + .gap_1p5() + .rounded_sm() + .border_1() + .border_color(field_border_color.opacity(0.5)) + .bg(row_background) + .px_2() + .py_1() + .hover(move |this| this.bg(hover_background).cursor_pointer()) + .on_click(move |_, _window, cx| { + on_multi_select_change( + elicitation_id.clone(), + field_name.clone(), + value.clone(), + !is_selected, + cx, + ); + }) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) + .child(Self::render_option_content(option)) + })) + .into_any_element() + } + }) + .when_some(error.cloned(), |this, error| { + this.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .into_any_element() + } + + fn render_single_select( + &self, + field_name: &str, + selected_value: Option<&String>, + options: Vec, + has_error: bool, + cx: &App, + ) -> AnyElement { + let entry_ix = self.entry_ix; + let border_color = if has_error { + Color::Error.color(cx) + } else { + cx.theme().colors().border.opacity(0.8) + }; + let elicitation_id = self.elicitation.id.clone(); + let field_name = field_name.to_string(); + let on_single_select_change = self.handlers.on_single_select_change.clone(); + + v_flex() + .gap_1() + .children(options.into_iter().map(move |option| { + let option_value = option.value.clone(); + let option_id = + format!("elicitation-select-option-{entry_ix}-{field_name}-{option_value}"); + let is_selected = + selected_value.is_some_and(|selected_value| selected_value == &option.value); + let row_background = Self::option_row_background(is_selected, cx); + let hover_background = Self::option_row_hover_background(is_selected, cx); + let control_background = Self::option_control_background(cx); + let elicitation_id = elicitation_id.clone(); + let field_name = field_name.clone(); + let on_single_select_change = on_single_select_change.clone(); + + h_flex() + .id(option_id) + .w_full() + .min_h(rems_from_px(28.)) + .items_start() + .gap_1p5() + .rounded_sm() + .border_1() + .border_color(border_color.opacity(0.5)) + .bg(row_background) + .px_2() + .py_1() + .hover(move |this| this.bg(hover_background).cursor_pointer()) + .on_click(move |_, _window, cx| { + on_single_select_change( + elicitation_id.clone(), + field_name.clone(), + option_value.clone(), + cx, + ); + }) + .child( + div() + .size(Checkbox::container_size()) + .flex_none() + .flex() + .items_center() + .justify_center() + .child(Self::render_radio_indicator( + is_selected, + border_color, + control_background, + )), + ) + .child(Self::render_option_content(option)) + })) + .into_any_element() + } + + fn render_option_content(option: ElicitationOption) -> Div { + v_flex() + .min_w_0() + .flex_1() + .gap_0p5() + .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .when_some(option.description, |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + } + + fn option_row_background(is_selected: bool, cx: &App) -> Hsla { + let editor_background = cx.theme().colors().editor_background; + if is_selected { + editor_background.blend(Color::Accent.color(cx).opacity(0.08)) + } else { + editor_background + } + } + + fn option_row_hover_background(is_selected: bool, cx: &App) -> Hsla { + let editor_background = cx.theme().colors().editor_background; + if is_selected { + editor_background.blend(Color::Accent.color(cx).opacity(0.1)) + } else { + cx.theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)) + } + } + + fn option_control_background(cx: &App) -> Hsla { + cx.theme().colors().editor_background + } + + fn render_radio_indicator(is_selected: bool, border_color: Hsla, background: Hsla) -> Div { + div() + .size_3() + .flex() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(border_color) + .bg(background) + .when(is_selected, |this| { + this.child(Indicator::dot().color(Color::Accent)) + }) + } + + fn render_url_elicitation(&self, mode: &acp::ElicitationUrlMode) -> AnyElement { + v_flex() + .gap_2() + .child(Self::render_url_summary(&mode.url)) + .into_any_element() + } + + fn render_url_summary(url: &str) -> AnyElement { + h_flex() + .gap_1() + .w_full() + .min_w_0() + .items_start() + .child( + div().h(rems_from_px(16.)).flex().items_center().child( + Icon::new(IconName::Link) + .size(IconSize::XSmall) + .color(Color::Muted), + ), + ) + .child(h_flex().min_w_0().flex_1().flex_wrap().children( + display_url_segments(url).into_iter().map(|segment| { + Label::new(segment) + .size(LabelSize::Small) + .color(Color::Muted) + }), + )) + .into_any_element() + } + + fn render_actions(&self, cx: &App) -> AnyElement { + let open_url = match &self.elicitation.request.mode { + acp::ElicitationMode::Url(mode) => Some(mode.url.clone()), + _ => None, + }; + let (accept_label, accept_icon, accept_icon_color) = if open_url.is_some() { + ("Open", IconName::ArrowUpRight, Color::Muted) + } else { + ("Submit", IconName::Check, Color::Success) + }; + let border_color = cx.theme().colors().border.opacity(0.8); + let on_submit = self.handlers.on_submit.clone(); + let on_open_url = self.handlers.on_open_url.clone(); + let on_decline = self.handlers.on_decline.clone(); + let on_cancel = self.handlers.on_cancel.clone(); + let submit_id = self.elicitation.id.clone(); + let decline_id = self.elicitation.id.clone(); + let cancel_id = self.elicitation.id.clone(); + + h_flex() + .w_full() + .p_1() + .gap_1() + .justify_end() + .border_t_1() + .border_color(border_color) + .child( + Button::new(("elicitation-accept", self.entry_ix), accept_label) + .start_icon( + Icon::new(accept_icon) + .size(IconSize::XSmall) + .color(accept_icon_color), + ) + .label_size(LabelSize::Small) + .on_click(move |_, window, cx| { + if let Some(url) = &open_url { + on_open_url(submit_id.clone(), url.clone(), window, cx); + } else { + on_submit(submit_id.clone(), window, cx); + } + }), + ) + .child( + Button::new(("elicitation-decline", self.entry_ix), "Decline") + .start_icon( + Icon::new(IconName::Close) + .size(IconSize::XSmall) + .color(Color::Error), + ) + .label_size(LabelSize::Small) + .on_click(move |_, window, cx| { + on_decline(decline_id.clone(), window, cx); + }), + ) + .child( + Button::new(("elicitation-cancel", self.entry_ix), "Cancel") + .label_size(LabelSize::Small) + .on_click(move |_, window, cx| { + on_cancel(cancel_id.clone(), window, cx); + }), + ) + .into_any_element() + } +} diff --git a/crates/agent_ui/src/conversation_view/thread_search_bar.rs b/crates/agent_ui/src/conversation_view/thread_search_bar.rs index f936edcd35a064..643cceda36a003 100644 --- a/crates/agent_ui/src/conversation_view/thread_search_bar.rs +++ b/crates/agent_ui/src/conversation_view/thread_search_bar.rs @@ -946,6 +946,7 @@ fn collect_markdowns( out.push(summary.clone()); } } + AgentThreadEntry::Elicitation(_) => {} AgentThreadEntry::ContextCompaction(_) => {} } out diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 96092f64cb3459..d59308be2d5f8c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -9,8 +9,8 @@ use agent_client_protocol::schema::v1 as acp; use std::cell::RefCell; use acp_thread::{ - PlanEntry, SandboxAuthorizationDetails, SandboxFallbackAuthorizationDetails, - SandboxNotAppliedReason, + Elicitation, ElicitationEntryId, ElicitationStatus, PlanEntry, SandboxAuthorizationDetails, + SandboxFallbackAuthorizationDetails, SandboxNotAppliedReason, }; use agent::{ SandboxStatusKey, SandboxStatusRefresh, SkillLoadingIssue, SkillLoadingIssueKind, @@ -20,11 +20,15 @@ use agent_settings::UserAgentsMd; use agent_skills::MAX_SKILL_DESCRIPTION_LEN; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; -use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; +use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; -use crate::completion_provider::AvailableSkill; +use crate::completion_provider::{AvailableSkill, PromptLocalCommand, pluralize}; use crate::message_editor::SharedSessionCapabilities; -use crate::ui::{SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip}; +use crate::ui::{ + SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip, TerminalSandboxWarning, + TerminalToolHeader, +}; +use crate::unicode_confusables; use db::kvp::KeyValueStore; use gpui::List; @@ -35,13 +39,17 @@ use language_model::{ FastModeConfirmation, LanguageModel, LanguageModelEffortLevel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, Speed, }; +use notifications::status_toast::StatusToast; use settings::{update_settings_file, update_settings_file_with_completion}; use ui::{ - ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, - Tab, + ButtonLike, CalloutBorderPosition, Checkbox, SpinnerLabel, SpinnerVariant, SplitButton, + SplitButtonStyle, Tab, ToggleState, }; use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME}; +use super::elicitation::{ + ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation, +}; use super::*; const DATA_RETENTION_LEARN_MORE_URL: &str = "https://support.claude.com/en/articles/15425996-data-retention-practices-for-mythos-class-models"; @@ -585,6 +593,10 @@ pub struct ThreadView { pub expanded_tool_call_raw_inputs: HashSet, collapsed_sandbox_authorization_details: HashSet, collapsed_sandbox_network_details: HashSet, + /// Sandbox escalation prompts whose "surprising Unicode" warning the user + /// has explicitly acknowledged. Until a prompt's tool call is in this set, + /// its allow buttons stay disabled. See [`Self::sandbox_confusable_findings`]. + acknowledged_confusable_warnings: HashSet, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -599,6 +611,7 @@ pub struct ThreadView { pub new_server_version_available: Option, pub resumed_without_history: bool, pub(crate) permission_selections: HashMap, + elicitation_form_states: HashMap, pub _cancel_task: Option>, _save_task: Option>, _draft_resolve_task: Option>, @@ -921,6 +934,20 @@ impl ThreadView { cx.notify(); }, )); + + // A "no model selected" error is stale as soon as the thread has a + // usable model + if let Some(native_thread) = native_connection.thread(thread.read(cx).session_id(), cx) + { + subscriptions.push(cx.subscribe( + &native_thread, + |this: &mut Self, _thread, _event: &agent::ModelChanged, cx| { + if matches!(this.thread_error, Some(ThreadError::NoModelSelected)) { + this.clear_thread_error(cx); + } + }, + )); + } } subscriptions.push(cx.observe(&message_editor, |this, editor, cx| { @@ -980,6 +1007,7 @@ impl ThreadView { expanded_tool_call_raw_inputs: HashSet::default(), collapsed_sandbox_authorization_details: HashSet::default(), collapsed_sandbox_network_details: HashSet::default(), + acknowledged_confusable_warnings: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -993,6 +1021,7 @@ impl ThreadView { is_loading_contents: false, new_server_version_available: None, permission_selections: HashMap::default(), + elicitation_form_states: HashMap::default(), _cancel_task: None, _save_task: None, _draft_resolve_task: None, @@ -1020,7 +1049,8 @@ impl ThreadView { }; this.sync_generating_indicator(cx); - this.sync_editor_mode_for_empty_state(cx); + this.sync_editor_mode(cx); + this.sync_existing_elicitation_states(window, cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); @@ -1107,11 +1137,48 @@ impl ThreadView { } MessageEditorEvent::LostFocus => {} MessageEditorEvent::SlashAutocompleteOpened => {} + MessageEditorEvent::LocalCommandInvoked(command) => { + self.run_local_command(*command, window, cx); + } MessageEditorEvent::InputAttempted { .. } => {} MessageEditorEvent::Edited => {} } } + fn run_local_command( + &mut self, + command: PromptLocalCommand, + window: &mut Window, + cx: &mut Context, + ) { + match command { + PromptLocalCommand::ThumbsUp => { + self.handle_feedback_click(ThreadFeedback::Positive, window, cx); + self.show_local_command_toast("Thanks for your feedback!", cx); + } + PromptLocalCommand::ThumbsDown => { + self.handle_feedback_click(ThreadFeedback::Negative, window, cx); + } + } + } + + fn show_local_command_toast(&self, message: impl Into, cx: &mut Context) { + // Shown after positive feedback, replacing the inline button state. + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + let toast = StatusToast::new(message, cx, |this, _cx| { + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + }); + workspace.toggle_status_toast(toast, cx); + }); + } + pub(crate) fn as_native_connection( &self, cx: &App, @@ -1264,6 +1331,7 @@ impl ThreadView { } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SlashAutocompleteOpened) => { } + ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::LocalCommandInvoked(_)) => {} ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Edited) => {} ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {} ViewEvent::OpenDiffLocation { @@ -1465,13 +1533,11 @@ impl ThreadView { let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { - let agent_id = self.agent_id.clone(); let server_view = self.server_view.clone(); move |window, cx| { ConversationView::handle_auth_required( server_view.clone(), AuthRequired::new(), - agent_id, connection, window, cx, @@ -2336,27 +2402,7 @@ impl ThreadView { pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { self.editor_expanded = is_expanded; - self.message_editor.update(cx, |editor, cx| { - if is_expanded { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, - }, - cx, - ) - } else { - let agent_settings = AgentSettings::get_global(cx); - editor.set_mode( - EditorMode::AutoHeight { - min_lines: agent_settings.message_editor_min_lines, - max_lines: Some(agent_settings.set_message_editor_max_lines()), - }, - cx, - ) - } - }); + self.sync_editor_mode(cx); cx.notify(); } @@ -2469,13 +2515,40 @@ impl ThreadView { } pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { + if self.pending_allow_blocked_by_confusables(cx) { + return; + } self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); } pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { + if self.pending_allow_blocked_by_confusables(cx) { + return; + } self.authorize_pending_with_granularity(true, window, cx); } + /// Whether the currently pending permission prompt is blocked by an + /// unacknowledged surprising-Unicode warning, so the keyboard allow + /// shortcuts must be ignored (mirroring the disabled allow buttons). + fn pending_allow_blocked_by_confusables(&self, cx: &Context) -> bool { + let session_id = self.thread.read(cx).session_id().clone(); + let Some((_, tool_call_id, _)) = self + .conversation + .read(cx) + .pending_tool_call(&session_id, cx) + else { + return false; + }; + self.thread.read(cx).entries().iter().any(|entry| { + matches!( + entry, + AgentThreadEntry::ToolCall(call) + if call.id == tool_call_id && self.sandbox_confusables_block_allow(call, cx) + ) + }) + } + pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { self.authorize_pending_with_granularity(false, window, cx); } @@ -2501,17 +2574,159 @@ impl ThreadView { Some(()) } - fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { - if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } + fn has_pending_request_elicitation(&self, cx: &App) -> bool { + self.server_view + .read_with(cx, |server_view, cx| { + server_view + .request_elicitation_store() + .is_some_and(|store| { + store.read(cx).elicitations().iter().any(|elicitation| { + matches!(elicitation.status, ElicitationStatus::Pending { .. }) + }) + }) + }) + .unwrap_or(false) + } + + pub fn sync_elicitation_state_for_entry( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let elicitation_id = { + let thread = self.thread.read(cx); + let Some(AgentThreadEntry::Elicitation(elicitation_id)) = thread.entries().get(index) + else { + return; + }; + elicitation_id.clone() + }; + + let thread = self.thread.read(cx); + let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| { + ( + elicitation_id.clone(), + matches!(elicitation.status, ElicitationStatus::Pending { .. }), + match &elicitation.request.mode { + acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()), + _ => None, + }, ) - } else { - false + }); + + let Some((id, is_pending, schema)) = entry else { + return; + }; + + if is_pending + && let Some(schema) = schema + && !self.elicitation_form_states.contains_key(&id) + { + self.elicitation_form_states + .insert(id, ElicitationFormState::new(&schema, window, cx)); + } else if !is_pending { + self.elicitation_form_states.remove(&id); + } + } + + fn sync_existing_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) { + let entry_count = self.thread.read(cx).entries().len(); + for index in 0..entry_count { + self.sync_elicitation_state_for_entry(index, window, cx); } } + #[cfg(test)] + pub(crate) fn has_elicitation_form_state(&self, id: &ElicitationEntryId) -> bool { + self.elicitation_form_states.contains_key(id) + } + + fn submit_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + let mode = self + .thread + .read(cx) + .elicitation(&elicitation_id) + .map(|(_, elicitation)| elicitation.request.mode.clone()); + + let Some(mode) = mode else { + return; + }; + + let response = match mode { + acp::ElicitationMode::Form(mode) => { + let Some(state) = self.elicitation_form_states.get(&elicitation_id) else { + return; + }; + match state.collect(&mode.requested_schema, cx) { + Ok(content) => { + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(content), + )) + } + Err(errors) => { + if let Some(state) = self.elicitation_form_states.get_mut(&elicitation_id) { + state.set_errors(errors); + } + cx.notify(); + return; + } + } + } + acp::ElicitationMode::Url(_) => acp::CreateElicitationResponse::new( + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()), + ), + _ => return, + }; + + self.respond_to_elicitation(elicitation_id, response, cx); + } + + fn decline_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + } + + fn cancel_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), + cx, + ); + } + + fn respond_to_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + let session_id = self.session_id.clone(); + self.elicitation_form_states.remove(&elicitation_id); + self.conversation.update(cx, |conversation, cx| { + conversation.respond_to_elicitation(session_id, elicitation_id, response, cx); + }); + cx.notify(); + } + fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, @@ -3021,7 +3236,7 @@ impl ThreadView { .size(IconSize::Small) }); - let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + let file_stats = DiffStats::single_file(diff.read(cx)); let buttons = self.render_edited_files_buttons( index, @@ -4186,11 +4401,59 @@ impl ThreadView { .children(draft_agent_selector) .child(self.render_send_button(cx)), ), - ), + ) + .children(self.render_status_footer(cx)), ) .into_any() } + /// A subtle, single-line "model · cwd · branch" status line rendered at the + /// bottom of the conversation footer. + fn render_status_footer(&self, cx: &Context) -> Option { + let model = self.active_model_name(cx); + let cwd = self + .thread + .read(cx) + .work_dirs() + .and_then(|dirs| dirs.ordered_paths().next().cloned()); + let cwd_display = cwd + .as_ref() + .map(|path| path.compact().to_string_lossy().into_owned()); + let cwd_full = cwd.as_ref().map(|path| path.display().to_string()); + let branch = self.project.upgrade().and_then(|project| { + project.read(cx).active_repository(cx).and_then(|repo| { + repo.read(cx) + .branch + .as_ref() + .map(|branch| branch.name().to_string()) + }) + }); + + let parts: Vec = [model.map(|model| model.to_string()), cwd_display, branch] + .into_iter() + .flatten() + .collect(); + if parts.is_empty() { + return None; + } + + Some( + h_flex() + .id("thread-status-footer") + .w_full() + .min_w_0() + .overflow_hidden() + .when_some(cwd_full, |this, cwd| this.tooltip(Tooltip::text(cwd))) + .child( + Label::new(parts.join(" · ")) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ) + .into_any_element(), + ) + } + fn render_draft_agent_selector(&self, cx: &mut Context) -> AnyElement { let Some(project) = self.project.upgrade() else { return div().into_any_element(); @@ -4864,7 +5127,7 @@ impl ThreadView { // Sandboxed by settings, but disabled for this thread: show the // settings scope (greyed) for context above the disabled status. (ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Unsandboxed) => { - let settings = augment_settings_sandbox_policy(settings_policy, baseline); + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); SandboxStatusTooltip::disabled_for_thread(sandbox_section( "Defined in your settings:", &settings, @@ -4875,10 +5138,11 @@ impl ThreadView { ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Sandboxed(thread_policy), ) => { - let settings = augment_settings_sandbox_policy(settings_policy, baseline); + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); + let thread = SandboxPolicyDisplay::from_policy(&thread_policy); // Omit the per-thread section when it grants nothing extra. - let thread = (!sandbox_policy_grants_nothing(&thread_policy)) - .then(|| sandbox_section("Allowed for this thread:", &thread_policy, false)); + let thread = (!sandbox_policy_grants_nothing(&thread)) + .then(|| sandbox_section("Allowed for this thread:", &thread, false)); SandboxStatusTooltip::enabled( sandbox_section("Defined in your settings:", &settings, true), thread, @@ -4906,7 +5170,7 @@ impl ThreadView { ); }), ) - .child(Divider::vertical().h_4()) + .child(Divider::vertical()) .into_any_element(), ) } @@ -5783,8 +6047,12 @@ impl Render for TokenUsageTooltip { Button::new( "open-project-rules", format!( - "{} project rules", - project_rules_count + "{} {}", + project_rules_count, + pluralize( + "project rule", + project_rules_count + ) ), ) .end_icon( @@ -5823,6 +6091,59 @@ impl Render for TokenUsageTooltip { } } +/// A display-ready snapshot of a sandbox policy for the status tooltip. +/// +/// The opaque `HostFilesystemLocation`s in a policy are stringified up front, +/// when this is built, so the tooltip state (which outlives the build and is +/// captured by the lazy tooltip closure) never holds the locations' fds open. +#[derive(Clone)] +struct SandboxPolicyDisplay { + fs: SandboxFsDisplay, + network: SandboxNetPolicy, +} + +/// The filesystem write-access portion of a [`SandboxPolicyDisplay`]. +#[derive(Clone)] +enum SandboxFsDisplay { + Unrestricted, + Restricted(Vec), +} + +/// A single writable entry to display in the sandbox tooltip: either a real host +/// location (already stringified for display) or the Linux-only host-isolated +/// `/tmp` overlay, which has no backing host path and is purely a label. +#[derive(Clone)] +enum WritableEntryDisplay { + Path(String), + // Only ever constructed on Linux (the bwrap `--tmpfs /tmp` overlay), so the + // variant is gated to match and avoid dead-code warnings elsewhere. + #[cfg(target_os = "linux")] + IsolatedTmp, +} + +impl SandboxPolicyDisplay { + /// Display a policy verbatim (used for the per-thread overrides, which carry + /// no implicit baseline grants). Takes the policy by reference and stringifies + /// its locations immediately, so no fd is retained past this call. + fn from_policy(policy: &SandboxPolicy) -> Self { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths, .. } => SandboxFsDisplay::Restricted( + writable_paths + .iter() + .map(|location| { + WritableEntryDisplay::Path(location.untrusted_path_display().to_string()) + }) + .collect(), + ), + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + } + } +} + /// Fold the always-granted baseline writable paths (the project's worktree /// roots, derived from the same source the terminal tool uses) and, on Linux, /// the host-isolated `/tmp` overlay into a settings policy for display. These @@ -5831,31 +6152,52 @@ impl Render for TokenUsageTooltip { /// section rather than stored. A no-op when the fs is unrestricted (rendered as /// "All paths"), since there's nothing to scope. fn augment_settings_sandbox_policy( - mut policy: SandboxPolicy, + policy: &SandboxPolicy, baseline: Vec, -) -> SandboxPolicy { - if let SandboxFsPolicy::Restricted { writable_paths } = &mut policy.fs { - let mut merged = baseline; - for path in writable_paths.drain(..) { - if !merged.contains(&path) { - merged.push(path); +) -> SandboxPolicyDisplay { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths, .. } => { + // Dedup by display string. We deliberately don't open the locations' + // fds to dedup by inode here: this is a display-only tooltip and the + // string is the location's identity for that purpose. The string can + // only diverge from the captured inode while a symlink-swap is + // actively in progress, and in that case the bind validator refuses + // to run the command at all (see the `sandbox` crate) — so showing the + // requested path is always safe, and not worth a blocking syscall on + // the render path. + let mut merged: Vec = Vec::new(); + let baseline_paths = baseline.iter().map(|path| path.display().to_string()); + let granted_paths = writable_paths + .iter() + .map(|location| location.untrusted_path_display().to_string()); + for path in baseline_paths.chain(granted_paths) { + if !merged.contains(&path) { + merged.push(path); + } } + // `mut` is only needed on Linux, where the isolated `/tmp` entry is + // pushed below. + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let mut entries: Vec = + merged.into_iter().map(WritableEntryDisplay::Path).collect(); + // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the + // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a + // real host path, so it can't be a captured location. + #[cfg(target_os = "linux")] + entries.push(WritableEntryDisplay::IsolatedTmp); + SandboxFsDisplay::Restricted(entries) } - // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the - // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a real - // host path, so it can't come from the path source above. - #[cfg(target_os = "linux")] - merged.push(PathBuf::from("/tmp (isolated)")); - *writable_paths = merged; - } - policy + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + } } -fn sandbox_section(title: &str, policy: &SandboxPolicy, show_empty: bool) -> SandboxSection { +fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool) -> SandboxSection { let write_empty = fs_grants_nothing(&policy.fs); let network_empty = network_grants_nothing(&policy.network); - let git_empty = git_grants_nothing(&policy.git); - let mut section = SandboxSection::new(title.to_string()); if show_empty || !write_empty { @@ -5868,42 +6210,17 @@ fn sandbox_section(title: &str, policy: &SandboxPolicy, show_empty: bool) -> San .group(SandboxGroup::new("Network Access").rows(sandbox_network_rows(&policy.network))); } - if !git_empty { - section = section - .group(SandboxGroup::new("Git Metadata Access").rows(sandbox_git_rows(&policy.git))); - } - section } /// Whether a policy grants nothing worth surfacing, used to decide whether to /// show the per-thread overrides section at all. -fn sandbox_policy_grants_nothing(policy: &SandboxPolicy) -> bool { - fs_grants_nothing(&policy.fs) - && network_grants_nothing(&policy.network) - && git_grants_nothing(&policy.git) -} - -/// Git access grants nothing to surface unless `.git` writes are allowed *and* -/// at least one `.git` directory is known. -fn git_grants_nothing(git: &GitSandboxPolicy) -> bool { - !git.allows_writes() || git.git_dirs().is_empty() -} - -/// Rows for the Git-access group: one row per writable `.git` directory (these -/// may live outside the project for a linked worktree). -fn sandbox_git_rows(git: &GitSandboxPolicy) -> Vec { - match git { - GitSandboxPolicy::Allowed { git_dirs } if !git_dirs.is_empty() => git_dirs - .iter() - .map(|path| SandboxRow::git(path.clone())) - .collect(), - _ => Vec::new(), - } +fn sandbox_policy_grants_nothing(policy: &SandboxPolicyDisplay) -> bool { + fs_grants_nothing(&policy.fs) && network_grants_nothing(&policy.network) } -fn fs_grants_nothing(fs: &SandboxFsPolicy) -> bool { - matches!(fs, SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty()) +fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool { + matches!(fs, SandboxFsDisplay::Restricted(entries) if entries.is_empty()) } fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { @@ -5916,15 +6233,24 @@ fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { /// Rows for the write-access group: a message for the "all"/"none" cases, or one /// row per granted path. -fn sandbox_fs_rows(fs: &SandboxFsPolicy) -> Vec { +fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec { match fs { - SandboxFsPolicy::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")], - SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty() => { + SandboxFsDisplay::Unrestricted => vec![SandboxRow::message( + "All paths except protected Git metadata", + )], + SandboxFsDisplay::Restricted(entries) if entries.is_empty() => { vec![SandboxRow::message("None")] } - SandboxFsPolicy::Restricted { writable_paths } => writable_paths + SandboxFsDisplay::Restricted(entries) => entries .iter() - .map(|path| SandboxRow::path(path.clone())) + .map(|entry| match entry { + // The display string was captured up front. + WritableEntryDisplay::Path(path) => SandboxRow::path(PathBuf::from(path)), + #[cfg(target_os = "linux")] + WritableEntryDisplay::IsolatedTmp => { + SandboxRow::path(PathBuf::from("/tmp (isolated)")) + } + }) .collect(), } } @@ -5965,9 +6291,8 @@ impl ThreadView { let rendered = this.render_entry(index, entries.len(), entry, window, cx); centered_container(rendered.into_any_element()).into_any_element() } else if this.generating_indicator_in_list { - let confirmation = entries - .last() - .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); + let confirmation = this.thread.read(cx).is_waiting_for_confirmation() + || this.has_pending_request_elicitation(cx); let rendered = this.render_generating(confirmation, cx); centered_container(rendered.into_any_element()).into_any_element() } else { @@ -5996,6 +6321,8 @@ impl ThreadView { .get(entry_ix.saturating_sub(1)) .is_none_or(|entry| !entry.is_indented()); + let mut assistant_message_is_blank = false; + let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self @@ -6230,6 +6557,8 @@ impl ThreadView { )) .into_any(); + assistant_message_is_blank = is_blank; + if is_blank { Empty.into_any() } else { @@ -6286,6 +6615,27 @@ impl ThreadView { tool_call.into_any() } } + AgentThreadEntry::Elicitation(elicitation_id) => { + let thread = self.thread.read(cx); + if let Some((_, elicitation)) = thread.elicitation(elicitation_id) + && should_render_elicitation(elicitation) + { + let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx); + + if let Some(handle) = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.focus_handle(cx)) + { + elicitation.track_focus(&handle).into_any() + } else { + elicitation.into_any() + } + } else { + Empty.into_any() + } + } AgentThreadEntry::CompletedPlan(entries) => { self.render_completed_plan(entries, window, cx) } @@ -6359,16 +6709,55 @@ impl ThreadView { primary }; - let needs_confirmation = Self::is_waiting_for_confirmation(entry); + let primary = if matches!(entry, AgentThreadEntry::AssistantMessage(_)) + && !assistant_message_is_blank + { + let user_message_index = thread + .read(cx) + .entries() + .iter() + .take(entry_ix) + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))); + + v_flex() + .w_full() + .child(primary) + .child(self.render_thread_controls( + &thread, + entry_ix, + Some(entry_ix), + entry_ix + 1 == total_entries, + user_message_index, + cx, + )) + .into_any_element() + } else { + primary + }; + + let is_assistant = matches!(entry, AgentThreadEntry::AssistantMessage(_)); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { + let last_assistant_index = thread + .read(cx) + .entries() + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_))); + v_flex() .w_full() .child(primary) - .when(!needs_confirmation, |this| { - this.child(self.render_thread_controls(&thread, cx)) + .when(!is_assistant, |this| { + this.child(self.render_thread_controls( + &thread, + entry_ix, + last_assistant_index, + true, + None, + cx, + )) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -6403,31 +6792,124 @@ impl ThreadView { } } - fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { - h_flex() - .key_context("AgentFeedbackMessageEditor") - .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { - this.thread_feedback.dismiss_comments(); - cx.notify(); - })) - .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { - this.submit_feedback_message(cx); - })) - .p_2() - .mb_2() - .mx_5() - .gap_1() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .child(div().w_full().child(editor)) - .child( - h_flex() - .child( - IconButton::new("dismiss-feedback-message", IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::XSmall) + fn render_elicitation( + &self, + entry_ix: usize, + elicitation: &Elicitation, + _window: &Window, + cx: &Context, + ) -> Div { + ElicitationCard::new( + entry_ix, + elicitation, + self.elicitation_form_states.get(&elicitation.id), + self.elicitation_card_handlers(cx), + ) + .render(cx) + } + + fn elicitation_card_handlers(&self, cx: &Context) -> ElicitationCardHandlers { + let view = cx.entity().downgrade(); + + ElicitationCardHandlers::new( + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.submit_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.decline_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.cancel_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, url, window, cx| { + cx.open_url(&url); + view.update(cx, |this, cx| { + this.submit_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) { + form.set_boolean(&field_name, value); + cx.notify(); + } + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) { + form.set_single_select(&field_name, value); + cx.notify(); + } + }) + .log_err(); + } + }, + move |elicitation_id, field_name, value, selected, cx| { + view.update(cx, |this, cx| { + if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) { + form.set_multi_select(&field_name, value, selected); + cx.notify(); + } + }) + .log_err(); + }, + ) + } + + fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { + h_flex() + .key_context("AgentFeedbackMessageEditor") + .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { + this.thread_feedback.dismiss_comments(); + cx.notify(); + })) + .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { + this.submit_feedback_message(cx); + })) + .p_2() + .mb_2() + .mx_5() + .gap_1() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .child(div().w_full().child(editor)) + .child( + h_flex() + .child( + IconButton::new("dismiss-feedback-message", IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) .shape(ui::IconButtonShape::Square) .on_click(cx.listener(move |this, _, _window, cx| { this.thread_feedback.dismiss_comments(); @@ -6448,45 +6930,55 @@ impl ThreadView { fn render_thread_controls( &self, thread: &Entity, + entry_ix: usize, + copy_response_index: Option, + is_thread_bottom: bool, + user_message_index: Option, cx: &Context, ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - if is_generating { + let needs_confirmation = thread.read(cx).is_waiting_for_confirmation() + || self.has_pending_request_elicitation(cx); + + if is_thread_bottom && (is_generating || needs_confirmation) { return Empty.into_any_element(); } - let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Open Thread as Markdown")) - .on_click(cx.listener(move |this, _, window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - this.open_thread_as_markdown(workspace, window, cx) - .detach_and_log_err(cx); - } - })); - - let scroll_to_recent_user_prompt = - IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) - .shape(ui::IconButtonShape::Square) + let copy_response_button = copy_response_index.map(|response_index| { + IconButton::new(("copy_agent_response", entry_ix), IconName::Copy) .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Copy This Agent Response")) .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_most_recent_user_prompt(cx); - })); + let entries = this.thread.read(cx).entries(); + if let Some(text) = Self::get_agent_message_content(entries, response_index, cx) + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + })) + }); + + let scroll_to_recent_user_prompt = IconButton::new( + ("scroll_to_recent_user_prompt", entry_ix), + IconName::UserArrowUp, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Scroll to User Message")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_user_message_index(user_message_index, cx); + })); - let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) - .shape(ui::IconButtonShape::Square) + let scroll_to_top = IconButton::new(("scroll_to_top", entry_ix), IconName::ArrowUp) .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Top")) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Scroll to Top")) .on_click(cx.listener(move |this, _, _, cx| { this.scroll_to_top(cx); })); - let show_stats = AgentSettings::get_global(cx).show_turn_stats; + let show_stats = is_thread_bottom && AgentSettings::get_global(cx).show_turn_stats; + let last_turn_clock = show_stats .then(|| { self.turn_fields @@ -6514,29 +7006,100 @@ impl ThreadView { }) .flatten(); - let mut container = h_flex() + let feedback_buttons = is_thread_bottom + .then(|| { + (self.is_subagent() && self.is_thread_feedback_enabled(cx)).then(|| { + let feedback = self.thread_feedback.feedback; + let tooltip_meta = + "Rating the thread sends all of your current conversation to the Zed team."; + + h_flex() + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Positive) => { + Tooltip::text("Thanks for your feedback!")(window, cx) + } + _ => Tooltip::with_meta( + "Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Negative) => Tooltip::text( + "We appreciate your feedback and will use it to improve in the future.", + )( + window, cx + ), + _ => Tooltip::with_meta( + "Not Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ) + }) + }) + .flatten(); + + h_flex() .w_full() - .py_2() - .px_5() - .gap_px() - .opacity(0.6) - .hover(|s| s.opacity(1.)) + .py_1p5() + .px_4() .justify_end() + .opacity(0.4) + .hover(|s| s.opacity(1.)) .when( last_turn_tokens_label.is_some() || last_turn_clock.is_some(), |this| { this.child( h_flex() - .gap_1() .px_1() - .when_some(last_turn_tokens_label, |this, label| this.child(label)) + .gap_1() + .when_some(last_turn_tokens_label, |this, label| { + this.child(label).child( + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5), + ) + }) .when_some(last_turn_clock, |this, label| this.child(label)), ) }, - ); + ) + .when_some(feedback_buttons, |this, buttons| this.child(buttons)) + .when_some(copy_response_button, |this, button| this.child(button)) + .child(scroll_to_recent_user_prompt) + .child(scroll_to_top) + .into_any_element() + } - let enable_thread_feedback = util::maybe!({ - let project = thread.read(cx).project().read(cx); + fn is_thread_feedback_enabled(&self, cx: &App) -> bool { + util::maybe!({ + let project = self.thread.read(cx).project().read(cx); let user_store = project.user_store(); if let Some(configuration) = user_store.read(cx).current_organization_configuration() { if !configuration.is_agent_thread_feedback_enabled { @@ -6546,86 +7109,60 @@ impl ThreadView { AgentSettings::get_global(cx).enable_feedback && self.thread.read(cx).connection().telemetry().is_some() - }); - - if enable_thread_feedback { - let feedback = self.thread_feedback.feedback; + }) + } - let tooltip_meta = || { - SharedString::new( - "Rating the thread sends all of your current conversation to the Zed team.", - ) - }; + // The local slash commands the message editor should currently expose. + // Kept in sync with the availability of the corresponding actions via + // `sync_local_commands`. + fn available_local_commands(&self, cx: &App) -> Vec { + let mut commands = Vec::new(); - container = container - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Positive) => { - Tooltip::text("Thanks for your feedback!")(window, cx) - } - _ => { - Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Positive, window, cx); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Negative) => { - Tooltip::text( - "We appreciate your feedback and will use it to improve in the future.", - )(window, cx) - } - _ => { - Tooltip::with_meta( - "Not Helpful Response", - None, - tooltip_meta(), - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Negative, window, cx); - })), - ); + if self.is_thread_feedback_enabled(cx) { + commands.push(PromptLocalCommand::ThumbsUp); + commands.push(PromptLocalCommand::ThumbsDown); } - container - .child(open_as_markdown) - .child(scroll_to_recent_user_prompt) - .child(scroll_to_top) - .into_any_element() + commands + } + + // Pushes the current set of available local commands to the message + // editor so they appear in its slash-command popup. + pub(crate) fn sync_local_commands(&self, cx: &App) { + let commands = self.available_local_commands(cx); + self.message_editor.read(cx).set_local_commands(commands); } - pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + fn render_request_elicitations(&self, cx: &Context) -> Vec { + let server_view = self.server_view.clone(); + let handlers_view = server_view.clone(); + server_view + .read_with(cx, |server_view, cx| { + let Some(connection) = server_view.request_elicitation_connection() else { + return Vec::new(); + }; + server_view.render_request_elicitations(&connection, handlers_view, cx) + }) + .unwrap_or_default() + } + + pub(crate) fn scroll_to_user_message_index( + &mut self, + user_message_index: Option, + cx: &mut Context, + ) { let entries = self.thread.read(cx).entries(); if entries.is_empty() { return; } - // Find the most recent user message and scroll it to the top of the viewport. + // Scroll to the provided user message, or fall back to the most recent one. // (Fallback: if no user message exists, scroll to the bottom.) - if let Some(ix) = entries - .iter() - .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) - { + if let Some(ix) = user_message_index.or_else(|| { + entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + }) { self.list_state.scroll_to(ListOffset { item_ix: ix, offset_in_item: px(0.0), @@ -6878,11 +7415,21 @@ impl ThreadView { open_markdown_in_workspace(thread_title, markdown, workspace, window, cx) } - pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { + pub(crate) fn sync_editor_mode(&mut self, cx: &mut Context) { let has_messages = self.list_state.item_count() > 0; let full_height_empty_state = !has_messages && !self.is_draft(cx); - let mode = if full_height_empty_state { + if !has_messages { + self.editor_expanded = false; + } + + let mode = if self.editor_expanded { + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + } + } else if full_height_empty_state { EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, @@ -7186,7 +7733,7 @@ impl ThreadView { block.markdown() } }; - md.map_or(false, |m| m.read(cx).selected_text().is_some()) + md.map_or(false, |m| m.read(cx).has_selection()) }) }) .unwrap_or(false); @@ -7349,6 +7896,7 @@ impl ThreadView { } } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -7469,17 +8017,10 @@ impl ThreadView { started_at.elapsed() }; - let header_id = - SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); let header_group = SharedString::from(format!( "terminal-tool-header-group-{}", terminal.entity_id() )); - let header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); let border_color = cx.theme().colors().border.opacity(0.6); let working_dir = working_dir @@ -7500,148 +8041,70 @@ impl ThreadView { .read(cx) .is_tool_call_expanded(&tool_call.id); - let header = h_flex() - .id(header_id) - .pt_1() - .pl_1p5() - .pr_1() - .flex_none() - .gap_1() - .justify_between() - .rounded_t_md() - .child( - div() - .id(("command-target-path", terminal.entity_id())) - .w_full() - .max_w_full() - .overflow_x_scroll() - .child( - Label::new(working_dir) - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - Disclosure::new( - SharedString::from(format!( - "terminal-tool-disclosure-{}", - terminal.entity_id() - )), - is_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&header_group) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this, _event, window, cx| { - this.entry_view_state.update(cx, |state, _cx| { - state.toggle_tool_call_expansion(&id); - }); - this.refresh_thread_search(window, cx); - cx.notify(); - } - })), - ) - .when(time_elapsed > Duration::from_secs(10), |header| { - header.child( - Label::new(format!("({})", duration_alt_display(time_elapsed))) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - }) - .when(!command_finished && !needs_confirmation, |header| { - header - .gap_1p5() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Muted) - .with_rotate_animation(2) + let truncated_tooltip = truncated_output.then(|| { + if let Some(output) = output { + if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { + format!( + "Output exceeded terminal max lines and was \ + truncated, the model received the first {}.", + format_file_size(output.content.len() as u64, true) ) - .child(div().h(relative(0.6)).ml_1p5().child(Divider::vertical().color(DividerColor::Border))) - .child( - IconButton::new( - SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), - IconName::Stop - ) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Stop This Command", - None, - "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", - cx, - ) - }) - .on_click({ - let terminal = terminal.clone(); - cx.listener(move |this, _event, _window, cx| { - terminal.update(cx, |terminal, cx| { - terminal.stop_by_user(cx); - }); - if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { - this.cancel_generation(cx); - } - }) - }), - ) - }) - .when(truncated_output, |header| { - let tooltip = if let Some(output) = output { - if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { - format!("Output exceeded terminal max lines and was \ - truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true)) - } else { - format!( - "Output is {} long, and to avoid unexpected token usage, \ - only {} was sent back to the agent.", - format_file_size(output.original_content_len as u64, true), - format_file_size(output.content.len() as u64, true) - ) - } } else { - "Output was truncated".to_string() - }; + format!( + "Output is {} long, and to avoid unexpected token usage, \ + only {} was sent back to the agent.", + format_file_size(output.original_content_len as u64, true), + format_file_size(output.content.len() as u64, true) + ) + } + } else { + "Output was truncated".to_string() + } + }); - header.child( - h_flex() - .id(("terminal-tool-truncated-label", terminal.entity_id())) - .gap_1() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Ignored), - ) - .child( - Label::new("Truncated") - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .tooltip(Tooltip::text(tooltip)), - ) - }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), - ) + let header = TerminalToolHeader::new( + terminal.entity_id().to_string(), + header_group, + working_dir, + is_expanded, + ) + .elapsed(time_elapsed) + .running(!command_finished && !needs_confirmation) + .on_toggle_expand(cx.listener({ + let id = tool_call.id.clone(); + move |this, _event, window, cx| { + this.entry_view_state.update(cx, |state, _cx| { + state.toggle_tool_call_expansion(&id); + }); + this.refresh_thread_search(window, cx); + cx.notify(); + } + })) + .on_stop({ + let terminal = terminal.clone(); + cx.listener(move |this, _event, _window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.stop_by_user(cx); + }); + if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { + this.cancel_generation(cx); + } }) -; + }) + .when_some(truncated_tooltip, |header, tooltip| { + header.truncated(tooltip) + }) + .when(tool_failed || command_failed, |header| { + header.failed( + output + .and_then(|o| o.exit_status) + .map(|status| status.code().unwrap_or(-1)), + ) + }) + .when_some(tool_call.sandbox_not_applied.as_ref(), |header, reason| { + header.sandbox_warning(self.sandbox_not_applied_warning(reason, cx)) + }) + .command_slot(command_element); let terminal_view = self .entry_view_state @@ -7659,17 +8122,7 @@ impl ThreadView { .rounded_md() }) .overflow_hidden() - .child( - v_flex() - .group(&header_group) - .bg(header_bg) - .text_xs() - .child(header) - .child(command_element), - ) - .when_some(tool_call.sandbox_not_applied.as_ref(), |this, reason| { - this.child(self.render_sandbox_not_applied_warning(reason, cx)) - }) + .child(header) .when(is_expanded && terminal_view.is_some(), |this| { this.child( div() @@ -7704,6 +8157,7 @@ impl ThreadView { }) .when_some(confirmation_options, |this, options| { let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx); + let allow_disabled = self.sandbox_confusables_block_allow(tool_call, cx); this.child(self.render_permission_buttons( self.thread.read(cx).session_id().clone(), is_first, @@ -7711,76 +8165,51 @@ impl ThreadView { entry_ix, tool_call.id.clone(), focus_handle, + allow_disabled, cx, )) }) .into_any() } - /// Render the "ran without sandbox" warning shown on a terminal tool card, - /// tailored to *why* the sandbox wasn't applied. - fn render_sandbox_not_applied_warning( + fn sandbox_not_applied_warning( &self, reason: &SandboxNotAppliedReason, cx: &Context, - ) -> AnyElement { - // (title, optional detail line) - let (title, detail): (SharedString, Option) = match reason { - SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( - "Couldn't create a sandbox".into(), - Some(error.user_facing_message().into()), - ), - SandboxNotAppliedReason::DisabledForThisThread => { - // The grant only exists because an earlier command failed to - // create a sandbox; surface that same explanation here. - let detail = self - .find_thread_sandbox_error(cx) - .map(|error| { - SharedString::from(format!( - "Allowed for this thread after the sandbox failed: {}", - error.user_facing_message() - )) - }) - .unwrap_or_else(|| { - "Unsandboxed execution is allowed for the rest of this thread.".into() - }); - ("Ran without sandbox".into(), Some(detail)) - } - }; + ) -> TerminalSandboxWarning { + // (title, detail line, docs section slug) + let (title, detail, docs_section): (SharedString, SharedString, Option<&'static str>) = + match reason { + SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( + "Couldn't create a sandbox".into(), + error.user_facing_message().into(), + Some(error.docs_section()), + ), + SandboxNotAppliedReason::DisabledForThisThread => { + // The grant only exists because an earlier command failed to + // create a sandbox; surface that same explanation here. + let thread_error = self.find_thread_sandbox_error(cx); + let detail = thread_error + .as_ref() + .map(|error| { + SharedString::from(format!( + "Allowed for this thread after the sandbox failed: {}", + error.user_facing_message() + )) + }) + .unwrap_or_else(|| { + "Unsandboxed execution is allowed for the rest of this thread.".into() + }); + let docs_section = thread_error.as_ref().map(|error| error.docs_section()); + ("Ran without sandbox".into(), detail, docs_section) + } + }; - h_flex() - .px_2() - .py_1() - .gap_1() - .border_t_1() - .border_color(cx.theme().status().warning_border) - .bg(cx.theme().status().warning_background.opacity(0.5)) - .child( - h_flex() - .min_w_0() - .flex_1() - .gap_1p5() - .items_start() - .child( - Icon::new(IconName::Warning) - .size(IconSize::XSmall) - .color(Color::Warning), - ) - .child( - v_flex() - .min_w_0() - .gap_0p5() - .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) - .when_some(detail, |this, detail| { - this.child( - Label::new(detail) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }), - ), - ) - .into_any_element() + TerminalSandboxWarning { + title, + detail, + docs_url: zed_urls::sandboxing_docs(docs_section, cx).into(), + } } /// Find the first terminal tool call in the thread whose sandbox couldn't be @@ -7927,7 +8356,13 @@ impl ThreadView { let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); - let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; + + let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; + + let has_content = !tool_call.content.is_empty() + || (should_show_raw_input && tool_call.raw_input.is_some()); + + let is_collapsible = has_content && !needs_confirmation; let mut is_open = self .entry_view_state .read(cx) @@ -7935,8 +8370,6 @@ impl ThreadView { is_open |= needs_confirmation; - let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; - let input_output_header = |label: SharedString| { Label::new(label) .size(LabelSize::XSmall) @@ -7946,7 +8379,7 @@ impl ThreadView { let tool_output_display = if is_open { match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => { + ToolCallStatus::WaitingForConfirmation { .. } => { let confirmation_content = v_flex() .w_full() .children(tool_call.content.iter().enumerate().map( @@ -7974,6 +8407,7 @@ impl ThreadView { entry_ix, &tool_call.id, details, + window, cx, )) }, @@ -8055,36 +8489,7 @@ impl ThreadView { ) }); - v_flex() - .w_full() - .map(|this| { - if layout == ToolCallLayout::Floating { - // Cap the content (e.g. a full plan awaiting - // approval) so the floating row can never - // consume the entire panel and squeeze the - // conversation list to zero height, while the - // permission buttons below stay visible. - this.child( - div() - .id(("floating-confirmation-content", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .child(confirmation_content), - ) - } else { - this.child(confirmation_content) - } - }) - .child(self.render_permission_buttons( - self.thread.read(cx).session_id().clone(), - self.is_first_tool_call(active_session_id, &tool_call.id, cx), - options, - entry_ix, - tool_call.id.clone(), - focus_handle, - cx, - )) - .into_any() + confirmation_content.into_any() } ToolCallStatus::Pending | ToolCallStatus::InProgress if is_edit @@ -8182,35 +8587,23 @@ impl ThreadView { None }; - v_flex() - .map(|this| { - if matches!( - layout, - ToolCallLayout::Embedded | ToolCallLayout::Floating - ) { - this - } else if use_card_layout { - this.my_1p5() - .rounded_md() - .border_1() - .when(failed_or_canceled, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() - } else { - this.my_1() - } - }) - .when(layout == ToolCallLayout::Standalone, |this| { - this.map(|this| { - if has_location && !use_card_layout { - this.ml_4() - } else { - this.ml_5() - } - }) - .mr_5() - }) + let permission_buttons = + if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status { + Some(self.render_permission_buttons( + self.thread.read(cx).session_id().clone(), + self.is_first_tool_call(active_session_id, &tool_call.id, cx), + options, + entry_ix, + tool_call.id.clone(), + focus_handle, + self.sandbox_confusables_block_allow(tool_call, cx), + cx, + )) + } else { + None + }; + + let body = v_flex() .map(|this| { if is_terminal_tool { this.child(self.render_collapsible_command( @@ -8385,7 +8778,78 @@ impl ThreadView { ) } }) - .children(tool_output_display) + .children(tool_output_display); + + v_flex() + .map(|this| { + if matches!(layout, ToolCallLayout::Embedded | ToolCallLayout::Floating) { + this + } else if use_card_layout { + this.my_1p5() + .rounded_md() + .border_1() + .when(failed_or_canceled, |this| this.border_dashed()) + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } + }) + .when(layout == ToolCallLayout::Standalone, |this| { + this.map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } + }) + .mr_5() + }) + .map(|this| { + if layout == ToolCallLayout::Floating { + this.child( + div() + .id(("floating-tool-call-body", entry_ix)) + .max_h_40() + .overflow_y_scroll() + .child(body), + ) + } else { + this.child(body) + } + }) + .children(permission_buttons) + } + + /// A small "Learn more" link to the sandboxing docs, deep-linked to + /// `section` when provided. Shared by the sandbox warning and the two + /// sandbox approval prompts so the user can always reach an explanation of + /// what they're being asked about. + fn render_sandbox_docs_link( + &self, + id: &'static str, + section: Option<&str>, + cx: &Context, + ) -> AnyElement { + let url = zed_urls::sandboxing_docs(section, cx); + let tooltip = format!("Opens {url}"); + // Wrap in a row so the button shrinks to its content width instead of + // stretching to fill the enclosing column. + h_flex() + .child( + Button::new(id, "Learn more") + .label_size(LabelSize::Small) + .color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |_, _, cx| cx.open_url(&url)), + ) + .into_any_element() } fn render_sandbox_authorization_details( @@ -8393,19 +8857,21 @@ impl ThreadView { entry_ix: usize, tool_call_id: &acp::ToolCallId, details: &SandboxAuthorizationDetails, + window: &Window, cx: &Context, ) -> AnyElement { let has_network = details.network_all_hosts || !details.network_hosts.is_empty(); let has_write = details.allow_fs_write_all || !details.write_paths.is_empty(); - if !has_network - && !has_write - && !details.allow_git_access - && !details.unsandboxed - && details.reason.is_empty() - { + if !has_network && !has_write && !details.unsandboxed && details.reason.is_empty() { return Empty.into_any_element(); } + let confusable_findings = if Self::confusable_warning_enabled(cx) { + Self::sandbox_confusable_findings(details) + } else { + Vec::new() + }; + let network_section = has_network.then(|| { let summary = if details.network_all_hosts { "any host".to_string() @@ -8484,33 +8950,29 @@ impl ThreadView { }), ) .when(has_host_list && is_open, |this| { - this.child( - v_flex() - .id(("sandbox-network-hosts-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .children(hosts.iter().enumerate().map(|(host_ix, host)| { - h_flex() - .min_w_0() - .px_2() - .py_1p5() - .bg(cx.theme().colors().editor_background) - .when(host_ix < hosts.len() - 1, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - Label::new(host.clone()) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - })), - ) + this.child(v_flex().children(hosts.iter().enumerate().map( + |(host_ix, host)| { + h_flex() + .min_w_0() + .px_2() + .py_1p5() + .bg(cx.theme().colors().editor_background) + .when(host_ix < hosts.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Label::new(host.clone()) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + }, + ))) }) }); let write_section = has_write.then(|| { let summary = if details.allow_fs_write_all { - "unrestricted".to_string() + "unrestricted except Git metadata".to_string() } else { format!( "{} {}", @@ -8581,27 +9043,23 @@ impl ThreadView { ("sandbox-authorization-details", entry_ix), is_open, ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - }), - ) - .when(has_path_list && is_open, |this| { - this.child( - v_flex() - .id(("sandbox-authorization-paths-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .children(paths.iter().enumerate().map(|(path_ix, path)| { - self.render_sandbox_authorization_path_row( - entry_ix, - path_ix, - path, - path_ix < paths.len() - 1, - cx, - ) - })), - ) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown), + ) + }), + ) + .when(has_path_list && is_open, |this| { + this.child(v_flex().children(paths.iter().enumerate().map( + |(path_ix, path)| { + self.render_sandbox_authorization_path_row( + entry_ix, + path_ix, + path, + path_ix < paths.len() - 1, + cx, + ) + }, + ))) }) }); @@ -8622,23 +9080,6 @@ impl ThreadView { ) }); - let git_access_section = details.allow_git_access.then(|| { - v_flex().px_2().py_1().gap_0p5().child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::GitBranch) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child( - Label::new("Git metadata access") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }); - let reason_section = (!details.reason.is_empty()).then(|| { v_flex() .px_2() @@ -8658,11 +9099,186 @@ impl ThreadView { v_flex() .border_t_1() .border_color(self.tool_card_border_color(cx)) + .when(!confusable_findings.is_empty(), |this| { + this.child(self.render_sandbox_confusable_warning( + tool_call_id, + &confusable_findings, + window, + cx, + )) + }) .children(network_section) .children(write_section) - .children(git_access_section) .children(unsandboxed_section) .children(reason_section) + .child( + h_flex() + .px_1() + .py_0p5() + .child(self.render_sandbox_docs_link( + "sandbox-authorization-docs-link", + None, + cx, + )), + ) + .into_any_element() + } + + /// Scan the hosts and paths in a sandbox escalation request for surprising + /// Unicode characters (homoglyphs, invisible characters, bidi overrides). + /// Returns, for each offending value, the display string shown to the user + /// and the distinct suspicious characters it contains. Hosts are decoded from + /// Punycode first, so the display string is the Unicode form the user should + /// scrutinize. Empty when nothing is surprising. + fn sandbox_confusable_findings( + details: &SandboxAuthorizationDetails, + ) -> Vec<(String, Vec)> { + let mut findings = Vec::new(); + for host in &details.network_hosts { + let (decoded, suspicious) = unicode_confusables::scan_host(host); + if !suspicious.is_empty() { + findings.push((decoded, suspicious)); + } + } + for path in &details.write_paths { + let display = path.display().to_string(); + let suspicious = unicode_confusables::scan(&display); + if !suspicious.is_empty() { + findings.push((display, suspicious)); + } + } + findings + } + + /// Whether the surprising-Unicode warning is enabled in settings (on by + /// default). When off, prompts neither show the banner nor gate their allow + /// buttons on it. + fn confusable_warning_enabled(cx: &App) -> bool { + AgentSettings::get_global(cx) + .sandbox_permissions + .warn_confusable_unicode + } + + /// Whether this tool call's sandbox escalation shows surprising Unicode that + /// the user hasn't acknowledged yet. While true, the prompt's allow buttons + /// stay disabled so the user can't grant access to a lookalike target + /// without first ticking the acknowledgement checkbox. + fn sandbox_confusables_block_allow(&self, tool_call: &ToolCall, cx: &App) -> bool { + if !Self::confusable_warning_enabled(cx) { + return false; + } + let Some(details) = tool_call.sandbox_authorization_details.as_ref() else { + return false; + }; + if self + .acknowledged_confusable_warnings + .contains(&tool_call.id) + { + return false; + } + !Self::sandbox_confusable_findings(details).is_empty() + } + + /// Red banner warning that a requested domain or path contains surprising + /// Unicode characters, with a checkbox the user must tick to unlock the + /// allow buttons. See [`Self::sandbox_confusables_block_allow`]. + fn render_sandbox_confusable_warning( + &self, + tool_call_id: &acp::ToolCallId, + findings: &[(String, Vec)], + window: &Window, + cx: &Context, + ) -> AnyElement { + let acknowledged = self.acknowledged_confusable_warnings.contains(tool_call_id); + let line_height = window.line_height(); + + v_flex() + .w_full() + .p_2() + .gap_2() + .border_t_1() + .border_color(cx.theme().status().error_border) + .bg(cx.theme().status().error_background.opacity(0.15)) + .child( + h_flex() + .w_full() + .gap_1p5() + .items_start() + .child( + h_flex() + .h(line_height) + .flex_none() + .justify_center() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Error), + ), + ) + .child( + v_flex().min_w_0().flex_1().gap_1().children(findings.iter().map( + |(value, suspicious)| { + v_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new(format!( + "“{value}” contains potentially surprising Unicode characters" + )) + .size(LabelSize::Small) + .color(Color::Error), + ) + .child(v_flex().min_w_0().pl_2().children( + suspicious.iter().map(|character| { + Label::new(format!("• {}", character.description())) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + }), + )) + }, + )), + ) + .child( + IconButton::new("configure-confusable-warning", IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Configure unicode confusables warning")) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), + target: None, + }), + cx, + ); + }), + ), + ) + .child( + Checkbox::new( + SharedString::from(format!("confusable-ack-{}", tool_call_id.0)), + if acknowledged { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("I understand and wish to proceed") + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + move |this, state: &ToggleState, _window, cx| { + if *state == ToggleState::Selected { + this.acknowledged_confusable_warnings + .insert(tool_call_id.clone()); + } else { + this.acknowledged_confusable_warnings.remove(&tool_call_id); + } + cx.notify(); + } + })), + ) .into_any_element() } @@ -8698,7 +9314,12 @@ impl ThreadView { .size(LabelSize::Small) .color(Color::Muted), ) - .child(Label::new(details.reason.clone()).size(LabelSize::Small)), + .child(Label::new(details.reason.clone()).size(LabelSize::Small)) + .child(self.render_sandbox_docs_link( + "sandbox-fallback-docs-link", + details.docs_section.as_deref(), + cx, + )), ) .into_any_element() } @@ -8766,6 +9387,9 @@ impl ThreadView { entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + // When true, the "allow" choices are disabled (e.g. an unacknowledged + // surprising-Unicode warning is showing). "Deny"/"Retry" stay enabled. + allow_disabled: bool, cx: &Context, ) -> Div { match options { @@ -8776,6 +9400,7 @@ impl ThreadView { entry_ix, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown( @@ -8786,6 +9411,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::DropdownWithPatterns { @@ -8800,6 +9426,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), } @@ -8814,6 +9441,7 @@ impl ThreadView { session_id: acp::SessionId, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + allow_disabled: bool, cx: &Context, ) -> Div { let selection = self.permission_selections.get(&tool_call_id); @@ -8868,13 +9496,14 @@ impl ThreadView { .gap_0p5() .child( Button::new(("allow-btn", entry_ix), "Allow") + .disabled(allow_disabled) .start_icon( Icon::new(IconName::Check) .size(IconSize::XSmall) .color(Color::Success), ) .label_size(LabelSize::Small) - .when(is_first, |this| { + .when(is_first && !allow_disabled, |this| { this.key_binding( KeyBinding::for_action_in( &AllowOnce as &dyn Action, @@ -9200,6 +9829,7 @@ impl ThreadView { entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + allow_disabled: bool, cx: &Context, ) -> Div { let mut seen_kinds: ArrayVec = ArrayVec::new(); @@ -9263,13 +9893,22 @@ impl ThreadView { } }; - let this = this.start_icon(icon); + // An "allow" choice is disabled while a surprising-Unicode + // warning is unacknowledged; "deny"/"retry" stay enabled. + let is_allow = matches!( + option.kind, + acp::PermissionOptionKind::AllowOnce + | acp::PermissionOptionKind::AllowAlways + ) && !is_retry; + let disabled = allow_disabled && is_allow; + + let this = this.start_icon(icon).disabled(disabled); let Some(action) = action else { return this; }; - if !is_first || seen_kinds.contains(&option.kind) { + if !is_first || disabled || seen_kinds.contains(&option.kind) { return this; } @@ -10489,13 +11128,17 @@ impl ThreadView { false, cx, ), - ThreadError::NoModelSelected => self.render_error_callout( - "No Model Selected", - "Select a model from the model picker below to get started.".into(), - false, - false, - cx, - ), + ThreadError::NoModelSelected => self + .render_model_not_available_error(cx) + .unwrap_or_else(|| { + self.render_error_callout( + "No Model Selected", + "Select a model from the model picker below to get started.".into(), + false, + false, + cx, + ) + }), ThreadError::ApiError { provider } => self.render_error_callout( "API Error", format!( @@ -10596,6 +11239,101 @@ impl ThreadView { .dismiss_action(self.dismiss_error_button(cx)) } + fn render_model_not_available_error(&self, cx: &mut Context) -> Option { + let thread = self.as_native_thread(cx)?; + + let has_authenticated_provider = + LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx); + + let (title, description): (SharedString, SharedString) = + match thread.read(cx).thread_model() { + agent::ThreadModel::Ready(_) => return None, + agent::ThreadModel::Unresolved(selected_model) => { + if let Some(provider) = LanguageModelRegistry::global(cx) + .read(cx) + .provider(&&selected_model.provider) + { + if !provider.is_authenticated(cx) { + ( + format!("Failed to authenticate with {} provider", provider.name()) + .into(), + "Open the settings to configure the selected provider".into(), + ) + } else { + ( + format!("Model {} was not found", selected_model.model.0).into(), + "You may need to reconfigure authentication for this provider" + .into(), + ) + } + } else { + ( + format!("Provider {} was not found", selected_model.provider).into(), + "Open the settings to configure providers".into(), + ) + } + } + agent::ThreadModel::Unset => { + if has_authenticated_provider { + ( + "No model selected".into(), + "Choose a different model or configure other providers to get started" + .into(), + ) + } else { + ( + "No model selected".into(), + "Configure a provider to get started".into(), + ) + } + } + }; + + let callout = Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title(title) + .description(description) + .actions_slot( + h_flex() + .gap_1() + .child(self.open_llm_providers_settings_button(cx)) + .when(has_authenticated_provider, |this| { + this.child(self.open_model_selector_button(cx)) + }), + ) + .dismiss_action(self.dismiss_error_button(cx)); + + Some(callout) + } + + fn open_llm_providers_settings_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("configure-llm-provider", "Configure Provider") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: "llm_providers".to_string(), + target: None, + }), + cx, + ); + })) + } + + fn open_model_selector_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("open-model-selector", "Select Model") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .key_binding(KeyBinding::for_action(&ToggleModelSelector, cx)) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action(ToggleModelSelector.boxed_clone(), cx); + })) + } + fn render_prompt_too_large_error(&self, cx: &mut Context) -> Callout { const MESSAGE: &str = "This conversation is too long for the model's context window. \ Start a new thread or remove some attached files to continue."; @@ -10652,7 +11390,6 @@ impl ThreadView { .on_click(cx.listener({ move |this, _, window, cx| { let server_view = this.server_view.clone(); - let agent_name = this.agent_id.clone(); this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { @@ -10665,7 +11402,6 @@ impl ThreadView { ConversationView::handle_auth_required( server_view, AuthRequired::new(), - agent_name, connection, window, cx, @@ -10691,6 +11427,16 @@ impl ThreadView { } } + /// The name of the currently selected model, when the agent exposes model + /// selection. Unlike [`Self::current_model_name`], never falls back to the + /// agent name. + pub(crate) fn active_model_name(&self, cx: &App) -> Option { + self.model_selector + .as_ref() + .and_then(|selector| selector.read(cx).active_model(cx)) + .map(|model| model.name.clone()) + } + fn render_any_thread_error( &mut self, error: SharedString, @@ -11411,6 +12157,11 @@ impl ThreadView { impl Render for ThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Keep the message editor's local slash commands in sync with the + // current availability of feedback/sharing, which can change between + // renders (settings, connection state, feature flags). + self.sync_local_commands(cx); + let has_messages = self.list_state.item_count() > 0; let list_state = self.list_state.clone(); @@ -11758,6 +12509,7 @@ impl Render for ThreadView { |this, version| this.child(self.render_new_version_callout(&version, cx)), ) .children(self.render_token_limit_callout(cx)) + .children(self.render_request_elicitations(cx)) .child(self.render_message_editor(window, cx)) } } @@ -11773,19 +12525,31 @@ pub(crate) fn open_link( return; }; - if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() { + let path_style = workspace.read(cx).path_style(cx); + if let Some(mention) = MentionUri::parse_hyperlink(&url, path_style).log_err() { + // Percent escapes in bare paths are ambiguous: prefer the decoded + // interpretation, falling back to the literal one (e.g. a file + // actually named `a%20b.rs`) only when the decoded path doesn't + // resolve in the project but the literal one does. + let resolves_in_project = |mention: &MentionUri, cx: &App| { + mention.abs_path().is_some_and(|abs_path| { + let project = workspace.read(cx).project().read(cx); + project + .find_project_path(abs_path, cx) + .is_some_and(|path| project.entry_for_path(&path, cx).is_some()) + }) + }; + let mention = match MentionUri::parse_hyperlink_literal(&url, path_style) { + Some(literal) + if !resolves_in_project(&mention, cx) && resolves_in_project(&literal, cx) => + { + literal + } + _ => mention, + }; workspace.update(cx, |workspace, cx| match mention { MentionUri::File { abs_path } => { - let project = workspace.project(); - let Some(path) = - project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return; - }; - - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); + open_abs_path_at_point(workspace, abs_path, None, window, cx); } MentionUri::PastedImage { .. } => {} MentionUri::Directory { abs_path } => { @@ -11809,7 +12573,7 @@ pub(crate) fn open_link( open_abs_path_at_point( workspace, path, - Point::new(*line_range.start(), 0), + Some(Point::new(*line_range.start(), 0)), window, cx, ); @@ -11822,7 +12586,7 @@ pub(crate) fn open_link( open_abs_path_at_point( workspace, path, - Point::new(*line_range.start(), column.unwrap_or(0)), + Some(Point::new(*line_range.start(), column.unwrap_or(0))), window, cx, ); @@ -11909,6 +12673,7 @@ mod tests { use super::*; use project::{FakeFs, Project}; use serde_json::json; + use std::path::Path; use util::path; use workspace::MultiWorkspace; @@ -12013,6 +12778,118 @@ mod tests { assert!(*active.path == *"src/main.rs"); }); } + + #[gpui::test] + async fn test_open_link_percent_escape_disambiguation(cx: &mut gpui::TestAppContext) { + crate::test_support::init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "a%20b.rs": "literal", + "a b.rs": "decoded", + "c d.rs": "", + "e%20f.rs": "", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + let open_link_and_active_path = |url: String, cx: &mut gpui::VisualTestContext| { + multi_workspace.update_in(cx, |_, window, cx| { + open_link(url.into(), &workspace_weak, window, cx); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .expect("file should be open") + .path + }) + }; + + // Both interpretations exist: the decoded one wins. + let path = open_link_and_active_path(path!("/project/a%20b.rs").to_string(), cx); + assert_eq!(*path, *"a b.rs"); + + // Only the decoded file exists. + let path = open_link_and_active_path(path!("/project/c%20d.rs").to_string(), cx); + assert_eq!(*path, *"c d.rs"); + + // Only the literally-named file exists: fall back to it. + let path = open_link_and_active_path(path!("/project/e%20f.rs").to_string(), cx); + assert_eq!(*path, *"e%20f.rs"); + } + + #[gpui::test] + async fn test_open_link_out_of_project_path(cx: &mut gpui::TestAppContext) { + crate::test_support::init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({"src": {"main.rs": ""}})) + .await; + fs.insert_tree(path!("/outside"), json!({"notes.md": "one\ntwo\nthree\n"})) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + // A nonexistent out-of-project path opens nothing, not even an + // empty buffer. + multi_workspace.update_in(cx, |_, window, cx| { + open_link( + path!("/outside/missing.md").to_string().into(), + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + assert!( + workspace.active_item(cx).is_none(), + "nothing should open for a nonexistent path" + ); + }); + + // An existing out-of-project file opens at the linked line. + multi_workspace.update_in(cx, |_, window, cx| { + open_link( + format!("{}:2", path!("/outside/notes.md")).into(), + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + let editor = workspace.read_with(cx, |workspace, cx| { + let item = workspace.active_item(cx).expect("file should be open"); + let project_path = item.project_path(cx).expect("item should have a path"); + let abs_path = workspace + .project() + .read(cx) + .absolute_path(&project_path, cx); + assert_eq!( + abs_path.as_deref(), + Some(Path::new(path!("/outside/notes.md"))) + ); + item.downcast::().expect("should be an editor") + }); + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(editor.selections.newest::(&snapshot).head().row, 1); + }); + } } const FAST_MODE_WARNING_NAMESPACE: &str = "fast-mode-warning-dismissed"; diff --git a/crates/agent_ui/src/diagnostics.rs b/crates/agent_ui/src/diagnostics.rs index 5a2423cae65aad..75c21d68b751cc 100644 --- a/crates/agent_ui/src/diagnostics.rs +++ b/crates/agent_ui/src/diagnostics.rs @@ -1,6 +1,7 @@ use anyhow::Result; use gpui::{App, AppContext as _, Entity, Task}; use language::{Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, ToOffset}; +use multi_buffer::MultiBuffer; use project::{DiagnosticSummary, Project}; use rope::Point; use std::{fmt::Write, ops::RangeInclusive, path::Path}; @@ -22,7 +23,7 @@ pub fn codeblock_fence_for_path( write!(text, "{path}").unwrap(); } else { - write!(text, "untitled").unwrap(); + write!(text, "{}", MultiBuffer::DEFAULT_TITLE).unwrap(); } if let Some(row_range) = row_range { diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index d19a1067baeeea..bdc79beacc9433 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -1,11 +1,14 @@ -use std::ops::Range; +use std::{ops::Range, sync::Arc}; use acp_thread::{AcpThread, AgentThreadEntry, AssistantMessageChunk}; use agent::ThreadStore; use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; -use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; +use editor::{ + Editor, EditorEvent, EditorMode, MinimapVisibility, RestoreOnlyUnstagedDiffHunkDelegate, + SizingBehavior, +}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ScrollHandle, TextStyleRefinement, WeakEntity, Window, @@ -377,6 +380,16 @@ impl EntryViewState { }); } } + AgentThreadEntry::Elicitation(_) => { + if !matches!(self.entries.get(index), Some(Entry::Elicitation { .. })) { + self.set_entry( + index, + Entry::Elicitation { + focus_handle: cx.focus_handle(), + }, + ); + } + } AgentThreadEntry::AssistantMessage(message) => { let entry = if let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) @@ -452,6 +465,7 @@ impl EntryViewState { match entry { Entry::UserMessage { .. } | Entry::AssistantMessage { .. } + | Entry::Elicitation { .. } | Entry::CompletedPlan | Entry::ContextCompaction => {} Entry::ToolCall(ToolCallEntry { content, .. }) => { @@ -521,6 +535,7 @@ pub enum Entry { UserMessage(Entity), AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), + Elicitation { focus_handle: FocusHandle }, CompletedPlan, ContextCompaction, } @@ -531,6 +546,7 @@ impl Entry { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(message) => Some(message.focus_handle.clone()), Self::ToolCall(tool_call) => Some(tool_call.focus_handle.clone()), + Self::Elicitation { focus_handle } => Some(focus_handle.clone()), Self::CompletedPlan | Self::ContextCompaction => None, } } @@ -540,6 +556,7 @@ impl Entry { Self::UserMessage(editor) => Some(editor), Self::AssistantMessage(_) | Self::ToolCall(_) + | Self::Elicitation { .. } | Self::CompletedPlan | Self::ContextCompaction => None, } @@ -570,6 +587,7 @@ impl Entry { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), Self::UserMessage(_) | Self::ToolCall(_) + | Self::Elicitation { .. } | Self::CompletedPlan | Self::ContextCompaction => None, } @@ -588,6 +606,7 @@ impl Entry { Self::ToolCall(ToolCallEntry { content, .. }) => !content.is_empty(), Self::UserMessage(_) | Self::AssistantMessage(_) + | Self::Elicitation { .. } | Self::CompletedPlan | Self::ContextCompaction => false, } @@ -606,6 +625,7 @@ impl Focusable for Entry { Self::UserMessage(editor) => editor.read(cx).focus_handle(cx), Self::AssistantMessage(message) => message.focus_handle.clone(), Self::ToolCall(tool_call) => tool_call.focus_handle.clone(), + Self::Elicitation { focus_handle } => focus_handle.clone(), Self::CompletedPlan | Self::ContextCompaction => cx.focus_handle(), } } @@ -665,7 +685,7 @@ fn create_editor_diff( editor.set_show_code_actions(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunks_as_unstaged(true, cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); editor.set_text_style_refinement(diff_editor_text_style_refinement(cx)); editor }) @@ -697,7 +717,7 @@ mod tests { use gpui::{AppContext as _, TestAppContext}; use parking_lot::RwLock; - use crate::entry_view_state::EntryViewState; + use crate::entry_view_state::{Entry, EntryViewState}; use crate::message_editor::SessionCapabilities; use multi_buffer::MultiBufferRow; use pretty_assertions::assert_matches; @@ -829,9 +849,86 @@ mod tests { ); } + #[gpui::test] + async fn test_elicitation_preserves_entry_index(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let connection = Rc::new(StubAgentConnection::new()); + let thread = cx + .update(|_, cx| { + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/project"))]), + cx, + ) + }) + .await + .unwrap(); + let session_id = thread.update(cx, |thread, _| thread.session_id().clone()); + + let _response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + cx.update(|_, cx| { + connection.send_update( + session_id, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + acp::ContentBlock::Text(acp::TextContent::new("hello")), + )), + cx, + ); + }); + + let view_state = cx.new(|_cx| { + EntryViewState::new( + workspace.downgrade(), + project.downgrade(), + None, + Arc::new(RwLock::new(SessionCapabilities::default())), + "Test Agent".into(), + ) + }); + + view_state.update_in(cx, |view_state, window, cx| { + view_state.sync_entry(0, &thread, window, cx); + view_state.sync_entry(1, &thread, window, cx); + }); + + view_state.read_with(cx, |view_state, _cx| { + assert!(matches!( + view_state.entry(0), + Some(Entry::Elicitation { .. }) + )); + assert!(matches!( + view_state.entry(1), + Some(Entry::AssistantMessage(_)) + )); + }); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { - let settings_store = SettingsStore::test(cx); + let mut settings_store = SettingsStore::test(cx); + settings_store.register_setting::(); cx.set_global(settings_store); theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 7f9ff087b38973..d7b73ecf57c915 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -650,7 +650,11 @@ mod tests { LanguageModelCompletionError, >, > { - unimplemented!() + Box::pin(std::future::ready(Err( + LanguageModelCompletionError::Other(anyhow::anyhow!( + "TestLanguageModel does not support streaming completions" + )), + ))) } } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 506c68cc0801b3..d04f222e186a5b 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -263,7 +263,7 @@ impl MentionSet { .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - log::error!("project path not found"); + log::error!("project path not found for image mention {abs_path:?}"); return Task::ready(()); }; let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx)); @@ -395,7 +395,9 @@ impl MentionSet { .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - return Task::ready(Err(anyhow!("project path not found"))); + return Task::ready(Err(anyhow!( + "project path not found for file mention {abs_path:?}" + ))); }; if is_raster_image_path(&abs_path) { @@ -465,7 +467,9 @@ impl MentionSet { .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - return Task::ready(Err(anyhow!("project path not found"))); + return Task::ready(Err(anyhow!( + "project path not found for symbol mention {abs_path:?}" + ))); }; let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); cx.spawn(async move |_, cx| { @@ -1222,7 +1226,9 @@ fn full_mention_for_directory( .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - return Task::ready(Err(anyhow!("project path not found"))); + return Task::ready(Err(anyhow!( + "project path not found for directory mention {abs_path:?}" + ))); }; let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else { return Task::ready(Err(anyhow!("project entry not found"))); @@ -1242,8 +1248,7 @@ fn full_mention_for_directory( |(worktree_path, full_path): (Arc, String)| { let rel_path = worktree_path .strip_prefix(&directory_path) - .log_err() - .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into()); + .map_or_else(|_| worktree_path.clone(), |rel_path| rel_path.into()); let open_task = project.update(cx, |project, cx| { project.buffer_store().update(cx, |buffer_store, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 41cc442546798c..3cfd7238413ce6 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -5,7 +5,7 @@ use crate::{ completion_provider::{ AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, PromptContextType, - SlashCommandCompletion, + PromptLocalCommand, SlashCommandCompletion, }, mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; @@ -140,10 +140,13 @@ impl SessionCapabilities { pub type SharedSessionCapabilities = Arc>; +pub type SharedLocalCommands = Arc>>; + struct MessageEditorCompletionDelegate { session_capabilities: SharedSessionCapabilities, has_thread_store: bool, message_editor: WeakEntity, + local_commands: SharedLocalCommands, } impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate { @@ -165,6 +168,18 @@ impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate { self.session_capabilities.read().completion_skills() } + fn available_local_commands(&self, _cx: &App) -> Vec { + self.local_commands.read().clone() + } + + fn run_local_command(&self, command: PromptLocalCommand, cx: &mut App) { + self.message_editor + .update(cx, |_this, cx| { + cx.emit(MessageEditorEvent::LocalCommandInvoked(command)); + }) + .ok(); + } + fn slash_autocomplete_invoked(&self, cx: &mut App) { // This may be called synchronously from inside a `MessageEditor` // update (e.g. when pasting a slash command triggers completions), @@ -190,6 +205,7 @@ pub struct MessageEditor { workspace: WeakEntity, project: WeakEntity, session_capabilities: SharedSessionCapabilities, + local_commands: SharedLocalCommands, agent_id: AgentId, thread_store: Option>, _subscriptions: Vec, @@ -214,6 +230,11 @@ pub enum MessageEditorEvent { /// editor. Used by `ThreadView` to fire the global-skills scan /// trigger; see `NativeAgent::ensure_skills_scan_started`. SlashAutocompleteOpened, + /// Emitted when the user confirms a local slash command (scrolling, + /// exporting, feedback) in this editor's completion popup. `ThreadView` + /// handles it by running the corresponding action; see + /// `handle_message_editor_event`. + LocalCommandInvoked(PromptLocalCommand), InputAttempted { attempt: InputAttempt, cursor_offset: usize, @@ -485,11 +506,13 @@ impl MessageEditor { editor }); let mention_set = cx.new(|_cx| MentionSet::new(project.clone(), thread_store.clone())); + let local_commands: SharedLocalCommands = Arc::new(RwLock::new(Vec::new())); let completion_provider = Rc::new(PromptCompletionProvider::new( MessageEditorCompletionDelegate { session_capabilities: session_capabilities.clone(), has_thread_store: thread_store.is_some(), message_editor: cx.weak_entity(), + local_commands: local_commands.clone(), }, editor.downgrade(), mention_set.clone(), @@ -585,6 +608,7 @@ impl MessageEditor { workspace, project, session_capabilities, + local_commands, agent_id, thread_store, _subscriptions: subscriptions, @@ -592,6 +616,10 @@ impl MessageEditor { } } + pub fn set_local_commands(&self, commands: Vec) { + *self.local_commands.write() = commands; + } + pub fn set_session_capabilities( &mut self, session_capabilities: SharedSessionCapabilities, @@ -1138,7 +1166,11 @@ impl MessageEditor { .update(cx, |project, cx| { project.project_path_for_absolute_path(&file_path, cx) }) - .ok_or_else(|| "project path not found".to_string())?; + .ok_or_else(|| { + format!( + "project path not found for pasted selection {file_path:?}" + ) + })?; let buffer = project .update(cx, |project, cx| project.open_buffer(project_path, cx)) @@ -2213,8 +2245,9 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option { #[cfg(test)] mod tests { - use std::{ops::Range, path::Path, path::PathBuf, sync::Arc}; + use std::{ops::Range, path::Path, path::PathBuf, rc::Rc, sync::Arc}; + use super::PromptLocalCommand; use acp_thread::MentionUri; use agent::{ThreadStore, outline}; use agent_client_protocol::schema::v1 as acp; @@ -2911,6 +2944,118 @@ mod tests { }); } + /// Local commands set via `set_local_commands` must surface in the + /// slash-command popup, and confirming one must emit + /// `MessageEditorEvent::LocalCommandInvoked` (so `ThreadView` can run the + /// corresponding action) without leaving the `/keyword` in the editor. + #[gpui::test] + async fn test_local_commands_complete_and_emit_event(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + let project = Project::test(app_state.fs.clone(), [path!("/dir").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 session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands( + acp::PromptCapabilities::default(), + Vec::new(), + ))); + + 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(), + None, + session_capabilities.clone(), + "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.read_with(&cx, |message_editor, _| { + message_editor.set_local_commands(vec![ + PromptLocalCommand::ThumbsUp, + PromptLocalCommand::ThumbsDown, + ]); + }); + + let invoked = Rc::new(std::cell::RefCell::new(Vec::new())); + let _subscription = cx.update(|_, cx| { + cx.subscribe(&message_editor, { + let invoked = invoked.clone(); + move |_editor, event, _cx| { + if let MessageEditorEvent::LocalCommandInvoked(command) = event { + invoked.borrow_mut().push(*command); + } + } + }) + }); + + // `/helpful` would fuzzy-match both commands ("helpful" is a + // subsequence of "not-helpful"), so drive the unambiguous keyword. + cx.simulate_input("/not-helpful"); + cx.run_until_parked(); + + editor.read_with(&cx, |editor, _| { + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + &[PromptLocalCommand::ThumbsDown.label().to_string()], + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.read_with(&cx, |editor, cx| { + // The `/keyword` text is removed when a local command is confirmed. + assert_eq!(editor.text(cx), ""); + assert!(!editor.has_visible_completions_menu()); + }); + + assert_eq!( + invoked.borrow().as_slice(), + &[PromptLocalCommand::ThumbsDown], + ); + } + /// Opening slash-command autocomplete must emit /// [`MessageEditorEvent::SlashAutocompleteOpened`]. `ThreadView` /// subscribes to that event to fire the global-skills scan trigger diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 00f989b50134ca..3007b5fa3eb336 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -18,8 +18,9 @@ use std::{ }; use ui::{ DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing, - PopoverMenuHandle, Tooltip, prelude::*, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; +use workspace::ToggleWorktreeSecurity; /// Trait for types that can provide and manage agent profiles pub trait ProfileProvider { @@ -34,6 +35,22 @@ pub trait ProfileProvider { /// Check if there is a model selected in the current context. fn model_selected(&self, cx: &App) -> bool; + + /// Whether the current workspace is restricted (has untrusted worktrees). + /// + /// In a restricted workspace, profiles that enable tools forbidden in + /// restricted mode are flagged, and the active built-in `write`/`ask` + /// profiles are downgraded to `minimal`. + fn is_restricted(&self, _cx: &App) -> bool { + false + } + + /// Whether the active profile has been downgraded to `minimal` because the + /// workspace is restricted (i.e. the user selected `write`/`ask`, but those + /// profiles aren't honored while restricted). + fn profile_downgraded(&self, _cx: &App) -> bool { + false + } } pub struct ProfileSelector { @@ -188,9 +205,23 @@ impl Render for ProfileSelector { IconName::ChevronDown }; + // Warn when the active profile is affected by a restricted workspace: + // either it was downgraded to `minimal`, or it still enables tools that + // are forbidden while restricted. + let show_warning = self.provider.is_restricted(cx) + && (self.provider.profile_downgraded(cx) + || !ProfilePickerDelegate::restricted_forbidden_tools(&profile_id, cx).is_empty()); + let trigger_button = Button::new("profile-selector", selected_profile) .label_size(LabelSize::Small) .color(Color::Muted) + .when(show_warning, |this| { + this.start_icon( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + }) .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); let tooltip: Box AnyView> = Box::new(Tooltip::element({ @@ -339,6 +370,22 @@ impl ProfilePickerDelegate { .collect() } + /// Tools enabled by a profile that are forbidden while the workspace is + /// restricted. Returns an empty list for profiles that are safe to use. + fn restricted_forbidden_tools(profile_id: &AgentProfileId, cx: &App) -> Vec { + let Some(profile) = AgentSettings::get_global(cx).profiles.get(profile_id) else { + return Vec::new(); + }; + profile + .tools + .iter() + .filter(|(name, enabled)| { + **enabled && !agent::tool_allowed_in_restricted_mode(name.as_ref()) + }) + .map(|(name, _)| SharedString::from(name.to_string())) + .collect() + } + fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> { match candidate.id.as_str() { builtin_profiles::WRITE => Some("Get help to write anything."), @@ -594,10 +641,17 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; let has_documentation = Self::documentation(candidate).is_some(); + let has_warning = self.provider.is_restricted(cx) + && !Self::restricted_forbidden_tools(&candidate.id, cx).is_empty(); + // The warning details are merged into the documentation aside, + // so hovering either the row or the icon shows a single popup. + let track_hover = has_documentation || has_warning; + let has_end_slot = is_active || has_warning; + Some( div() .id(("profile-picker-item", ix)) - .when(has_documentation, |this| { + .when(track_hover, |this| { this.on_hover(cx.listener(move |picker, hovered, _, cx| { if *hovered { picker.delegate.hovered_index = Some(ix); @@ -616,11 +670,23 @@ impl PickerDelegate for ProfilePickerDelegate { candidate.name.clone(), entry.positions.clone(), )) - .when(is_active, |this| { + .when(has_end_slot, |this| { this.end_slot( - div() + h_flex() + .gap_1() .pr_2() - .child(Icon::new(IconName::Check).color(Color::Accent)), + .when(has_warning, |this| { + this.child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + }) + .when(is_active, |this| { + this.child( + Icon::new(IconName::Check).color(Color::Accent), + ) + }), ) }), ) @@ -644,13 +710,61 @@ impl PickerDelegate for ProfilePickerDelegate { }; let candidate = self.candidates.get(entry.candidate_index)?; - let docs_aside = Self::documentation(candidate)?.to_string(); + let description = Self::documentation(candidate).map(|docs| docs.to_string()); + let forbidden_tools = if self.provider.is_restricted(cx) { + Self::restricted_forbidden_tools(&candidate.id, cx) + } else { + Vec::new() + }; + + // Nothing to show: no description and no restricted-tool warning. + if description.is_none() && forbidden_tools.is_empty() { + return None; + } let side = documentation_aside_side(cx); Some(DocumentationAside { side, - render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()), + render: Rc::new(move |cx| { + v_flex() + .gap_1p5() + .when_some(description.clone(), |this, description| { + this.child(Label::new(description)) + }) + .when(!forbidden_tools.is_empty(), |this| { + this.when(description.is_some(), |this| { + this.child( + div() + .border_t_1() + .border_color(cx.theme().colors().border_variant), + ) + }) + .child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new("Disabled in Restricted Mode") + .size(LabelSize::Small), + ), + ) + .children(forbidden_tools.iter().map(|tool| { + Label::new(format!("• {tool}")) + .size(LabelSize::Small) + .color(Color::Muted) + })), + ) + }) + .into_any_element() + }), }) } @@ -664,29 +778,66 @@ impl PickerDelegate for ProfilePickerDelegate { cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); + let is_restricted = self.provider.is_restricted(cx); Some( - h_flex() + v_flex() .w_full() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .p_1p5() .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in( - &ManageProfiles::default(), - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); - }), + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in( + &ManageProfiles::default(), + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action( + ManageProfiles::default().boxed_clone(), + cx, + ); + }), + ), ) + .when(is_restricted, |this| { + this.child( + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("restricted-mode", "Restricted Mode") + .full_width() + .style(ButtonStyle::Tinted(TintColor::Warning)) + .color(Color::Warning) + .start_icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .tooltip(Tooltip::text( + "Some tools are disabled. Click to review trust settings.", + )) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleWorktreeSecurity.boxed_clone(), + cx, + ); + }), + ), + ) + }) .into_any(), ) } diff --git a/crates/agent_ui/src/terminal_thread_metadata_store.rs b/crates/agent_ui/src/terminal_thread_metadata_store.rs index 70bfb8b26e686a..96346b71a0d9fb 100644 --- a/crates/agent_ui/src/terminal_thread_metadata_store.rs +++ b/crates/agent_ui/src/terminal_thread_metadata_store.rs @@ -53,6 +53,12 @@ pub struct TerminalThreadMetadata { pub worktree_paths: WorktreePaths, pub remote_connection: Option, pub working_directory: Option, + /// Label of the agent CLI last detected in this terminal ("claude", + /// "codex"), used to relaunch it on restore. + pub agent: Option, + /// The agent's captured session reference, used to resume the + /// conversation on restore. + pub agent_session: Option, } impl TerminalThreadMetadata { @@ -194,7 +200,17 @@ impl TerminalThreadMetadataStore { } pub fn entries(&self) -> impl Iterator + '_ { - self.terminals.values() + Self::sorted_by_created_at(self.terminals.values().collect()) + } + + // The in-memory indices are hash-based, so without an explicit sort the + // iteration order (and therefore sidebar listing and restore order) would + // change between runs. + fn sorted_by_created_at( + mut entries: Vec<&TerminalThreadMetadata>, + ) -> impl Iterator { + entries.sort_by_key(|metadata| (metadata.created_at, metadata.terminal_id)); + entries.into_iter() } pub fn reload_task(&self) -> Shared> { @@ -208,17 +224,20 @@ impl TerminalThreadMetadataStore { path_list: &PathList, remote_connection: Option<&'a RemoteConnectionOptions>, ) -> impl Iterator + 'a { - self.terminals_by_paths - .get(path_list) - .into_iter() - .flatten() - .filter_map(|id| self.terminals.get(id)) - .filter(move |terminal| { - same_remote_connection_identity( - terminal.remote_connection.as_ref(), - remote_connection, - ) - }) + Self::sorted_by_created_at( + self.terminals_by_paths + .get(path_list) + .into_iter() + .flatten() + .filter_map(|id| self.terminals.get(id)) + .filter(|terminal| { + same_remote_connection_identity( + terminal.remote_connection.as_ref(), + remote_connection, + ) + }) + .collect(), + ) } pub fn entries_for_main_worktree_path<'a>( @@ -226,17 +245,20 @@ impl TerminalThreadMetadataStore { path_list: &PathList, remote_connection: Option<&'a RemoteConnectionOptions>, ) -> impl Iterator + 'a { - self.terminals_by_main_paths - .get(path_list) - .into_iter() - .flatten() - .filter_map(|id| self.terminals.get(id)) - .filter(move |terminal| { - same_remote_connection_identity( - terminal.remote_connection.as_ref(), - remote_connection, - ) - }) + Self::sorted_by_created_at( + self.terminals_by_main_paths + .get(path_list) + .into_iter() + .flatten() + .filter_map(|id| self.terminals.get(id)) + .filter(|terminal| { + same_remote_connection_identity( + terminal.remote_connection.as_ref(), + remote_connection, + ) + }) + .collect(), + ) } pub fn path_is_referenced_by_terminal( @@ -445,20 +467,26 @@ struct TerminalThreadMetadataDb(ThreadSafeConnection); impl Domain for TerminalThreadMetadataDb { const NAME: &str = stringify!(TerminalThreadMetadataDb); - const MIGRATIONS: &[&str] = &[sql!( - CREATE TABLE IF NOT EXISTS sidebar_terminal_threads( - terminal_id TEXT PRIMARY KEY, - title TEXT NOT NULL, - custom_title TEXT, - created_at TEXT NOT NULL, - working_directory TEXT, - folder_paths TEXT, - folder_paths_order TEXT, - main_worktree_paths TEXT, - main_worktree_paths_order TEXT, - remote_connection TEXT - ) STRICT; - )]; + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE IF NOT EXISTS sidebar_terminal_threads( + terminal_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + custom_title TEXT, + created_at TEXT NOT NULL, + working_directory TEXT, + folder_paths TEXT, + folder_paths_order TEXT, + main_worktree_paths TEXT, + main_worktree_paths_order TEXT, + remote_connection TEXT + ) STRICT; + ), + sql!( + ALTER TABLE sidebar_terminal_threads ADD COLUMN agent TEXT; + ALTER TABLE sidebar_terminal_threads ADD COLUMN agent_session TEXT; + ), + ]; } db::static_connection!(TerminalThreadMetadataDb, []); @@ -468,7 +496,7 @@ impl TerminalThreadMetadataDb { self.select::( "SELECT terminal_id, title, custom_title, created_at, \ working_directory, folder_paths, folder_paths_order, main_worktree_paths, \ - main_worktree_paths_order, remote_connection \ + main_worktree_paths_order, remote_connection, agent, agent_session \ FROM sidebar_terminal_threads \ ORDER BY created_at DESC", )?() @@ -502,10 +530,12 @@ impl TerminalThreadMetadataDb { .map(serde_json::to_string) .transpose() .context("serialize terminal thread remote connection")?; + let agent = row.agent.clone(); + let agent_session = row.agent_session.clone(); self.write(move |conn| { - let sql = "INSERT INTO sidebar_terminal_threads(terminal_id, title, custom_title, created_at, working_directory, folder_paths, folder_paths_order, main_worktree_paths, main_worktree_paths_order, remote_connection) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + let sql = "INSERT INTO sidebar_terminal_threads(terminal_id, title, custom_title, created_at, working_directory, folder_paths, folder_paths_order, main_worktree_paths, main_worktree_paths_order, remote_connection, agent, agent_session) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) \ ON CONFLICT(terminal_id) DO UPDATE SET \ title = excluded.title, \ custom_title = excluded.custom_title, \ @@ -515,7 +545,9 @@ impl TerminalThreadMetadataDb { folder_paths_order = excluded.folder_paths_order, \ main_worktree_paths = excluded.main_worktree_paths, \ main_worktree_paths_order = excluded.main_worktree_paths_order, \ - remote_connection = excluded.remote_connection"; + remote_connection = excluded.remote_connection, \ + agent = excluded.agent, \ + agent_session = excluded.agent_session"; let mut stmt = Statement::prepare(conn, sql)?; let mut i = stmt.bind(&terminal_id, 1)?; i = stmt.bind(&title, i)?; @@ -526,7 +558,9 @@ impl TerminalThreadMetadataDb { i = stmt.bind(&folder_paths_order, i)?; i = stmt.bind(&main_worktree_paths, i)?; i = stmt.bind(&main_worktree_paths_order, i)?; - stmt.bind(&remote_connection, i)?; + i = stmt.bind(&remote_connection, i)?; + i = stmt.bind(&agent, i)?; + stmt.bind(&agent_session, i)?; stmt.exec() }) .await @@ -562,6 +596,8 @@ impl Column for TerminalThreadMetadata { Column::column(statement, next)?; let (remote_connection_json, next): (Option, i32) = Column::column(statement, next)?; + let (agent, next): (Option, i32) = Column::column(statement, next)?; + let (agent_session, next): (Option, i32) = Column::column(statement, next)?; let folder_paths = folder_paths_str .map(|paths| { @@ -601,6 +637,8 @@ impl Column for TerminalThreadMetadata { worktree_paths, remote_connection, working_directory: working_directory.map(PathBuf::from), + agent, + agent_session, }, next, )) @@ -630,6 +668,8 @@ mod tests { worktree_paths, remote_connection: None, working_directory: None, + agent: None, + agent_session: None, } } @@ -659,6 +699,206 @@ mod tests { assert_eq!(metadata.display_title().as_ref(), "Fix bug"); } + async fn reload_store(cx: &mut TestAppContext) { + let reload_task = cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + store.update(cx, |store, cx| store.reload(cx)); + store.read(cx).reload_task() + }); + reload_task.await; + } + + #[gpui::test] + async fn test_save_and_reload_round_trip_preserves_metadata(cx: &mut TestAppContext) { + init_test(cx); + + let folder_paths = PathList::new(&[Path::new("/repo")]); + let mut saved = metadata( + "Dev Server", + WorktreePaths::from_folder_paths(&folder_paths), + ); + saved.custom_title = Some("Fix bug".into()); + saved.working_directory = Some(PathBuf::from("/repo/src")); + saved.agent = Some("claude".to_string()); + saved.agent_session = Some("11111111-2222-3333-4444-555555555555".to_string()); + let terminal_id = saved.terminal_id; + let created_at = saved.created_at; + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(saved, cx); + }); + }); + cx.run_until_parked(); + reload_store(cx).await; + + cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + let store = store.read(cx); + let entry = store + .entry(terminal_id) + .expect("entry should survive a reload from the database"); + assert_eq!(entry.title.as_ref(), "Dev Server"); + assert_eq!(entry.custom_title.as_deref(), Some("Fix bug")); + assert_eq!(entry.working_directory, Some(PathBuf::from("/repo/src"))); + assert_eq!(entry.created_at, created_at); + assert_eq!(entry.folder_paths().paths(), folder_paths.paths()); + assert_eq!(entry.agent.as_deref(), Some("claude")); + assert_eq!( + entry.agent_session.as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); + }); + } + + #[gpui::test] + async fn test_working_directory_and_custom_title_updates_persist_across_reload( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let folder_paths = PathList::new(&[Path::new("/repo")]); + let mut saved = metadata( + "Dev Server", + WorktreePaths::from_folder_paths(&folder_paths), + ); + saved.working_directory = Some(PathBuf::from("/repo")); + let terminal_id = saved.terminal_id; + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(saved.clone(), cx); + }); + }); + cx.run_until_parked(); + + saved.working_directory = Some(PathBuf::from("/repo/deep/dir")); + saved.custom_title = Some("Renamed".into()); + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(saved, cx); + }); + }); + cx.run_until_parked(); + reload_store(cx).await; + + cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + let store = store.read(cx); + let entry = store + .entry(terminal_id) + .expect("updated entry should survive a reload"); + assert_eq!( + entry.working_directory, + Some(PathBuf::from("/repo/deep/dir")) + ); + assert_eq!(entry.custom_title.as_deref(), Some("Renamed")); + assert_eq!( + store.entries_for_path(&folder_paths, None).count(), + 1, + "an update must not create a second row" + ); + }); + } + + #[gpui::test] + async fn test_entries_are_ordered_by_created_at(cx: &mut TestAppContext) { + init_test(cx); + + let folder_paths = PathList::new(&[Path::new("/repo")]); + let base = Utc::now(); + let mut oldest = metadata("first", WorktreePaths::from_folder_paths(&folder_paths)); + oldest.created_at = base - chrono::Duration::seconds(60); + let mut middle = metadata("second", WorktreePaths::from_folder_paths(&folder_paths)); + middle.created_at = base; + let mut newest = metadata("third", WorktreePaths::from_folder_paths(&folder_paths)); + newest.created_at = base + chrono::Duration::seconds(60); + let expected_ids = vec![oldest.terminal_id, middle.terminal_id, newest.terminal_id]; + + // Save out of creation order to prove the sort is not insertion order. + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(newest, cx); + store.save(oldest, cx); + store.save(middle, cx); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + let store = store.read(cx); + assert_eq!( + store + .entries_for_path(&folder_paths, None) + .map(|entry| entry.terminal_id) + .collect::>(), + expected_ids + ); + assert_eq!( + store + .entries() + .map(|entry| entry.terminal_id) + .collect::>(), + expected_ids + ); + }); + + // The order also holds after reloading from the database. + reload_store(cx).await; + cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + assert_eq!( + store + .read(cx) + .entries_for_path(&folder_paths, None) + .map(|entry| entry.terminal_id) + .collect::>(), + expected_ids + ); + }); + } + + #[gpui::test] + async fn test_delete_removes_row_from_database(cx: &mut TestAppContext) { + init_test(cx); + + let folder_paths = PathList::new(&[Path::new("/repo")]); + let first = metadata("first", WorktreePaths::from_folder_paths(&folder_paths)); + let second = metadata("second", WorktreePaths::from_folder_paths(&folder_paths)); + let first_id = first.terminal_id; + let second_id = second.terminal_id; + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(first, cx); + store.save(second, cx); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.delete(first_id, cx); + }); + }); + cx.run_until_parked(); + reload_store(cx).await; + + cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + let store = store.read(cx); + assert!( + store.entry(first_id).is_none(), + "deleted terminal must not come back after a reload" + ); + assert!( + store.entry(second_id).is_some(), + "deleting one terminal must not affect the other" + ); + }); + } + #[gpui::test] async fn test_change_worktree_paths_reindexes_terminal_metadata(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 8e39f5c91bdaf8..9cb0972a77cddb 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -95,7 +95,14 @@ pub fn init(cx: &mut App) { /// Migrate existing thread metadata from native agent thread store to the new metadata storage. /// We skip migrating threads that do not have a project. /// -/// TODO: Remove this after N weeks of shipping the sidebar +/// Policy (2026-07): keep this migration indefinitely. Users upgrading from +/// pre-sidebar builds still need it to avoid stranding their existing +/// threads, it is idempotent (threads whose session id already has a +/// metadata row are skipped), and it is cheap on every launch once there is +/// nothing left to migrate. Revisit only if a future release milestone drops +/// support for upgrading directly from pre-sidebar builds. The reload-await +/// ordering it depends on is pinned by +/// `test_migration_awaits_thread_store_reload`. fn migrate_thread_metadata(cx: &mut App) -> Task> { let store = ThreadMetadataStore::global(cx); let db = store.read(cx).db.clone(); @@ -1273,7 +1280,6 @@ impl ThreadMetadataStore { return; }; - let is_draft = view.is_draft(cx); let thread_ref = thread.read(cx); // Collab-hosted threads don't own their metadata locally. if thread_ref.project().read(cx).is_via_collab() { @@ -1281,6 +1287,12 @@ impl ThreadMetadataStore { } let existing_thread = self.entry(thread_id); + // A row that was already promoted (e.g. saved externally with a + // session id) must never be demoted back to a sessionless draft, + // even if the view still considers itself a draft because no user + // prompt was submitted through it. + let is_draft = view.is_draft(cx) && existing_thread.is_none_or(|t| t.is_draft()); + // New ACP sessions exist before the user sends. Keep draft metadata // sessionless until the conversation is promoted by user input. let session_id = if is_draft { @@ -1288,7 +1300,16 @@ impl ThreadMetadataStore { } else { Some(thread_ref.session_id().clone()) }; - let title = if is_draft { None } else { thread_ref.title() }; + // Preserve an externally saved title when the live thread has not + // produced one of its own yet; titles never organically revert to + // None, so dropping the stored one would only lose information. + let title = if is_draft { + None + } else { + thread_ref + .title() + .or_else(|| existing_thread.and_then(|t| t.title.clone())) + }; let title_override = existing_thread.and_then(|t| t.title_override.clone()); let updated_at = Utc::now(); @@ -2690,13 +2711,13 @@ mod tests { let store = ThreadMetadataStore::global(cx).read(cx); let entry = store.entry(thread_id).expect("draft metadata row"); assert!(entry.is_draft(), "still a draft after title update"); - assert_eq!( - entry.title.as_ref().map(|t| t.as_ref()), - Some("Draft Thread") - ); + // Draft rows never persist agent-provided titles; the title only + // lands in metadata once the draft is promoted. + assert_eq!(entry.title, None); }); - // Pushing content promotes the draft: session_id is now populated. + // Pushing content promotes the draft: session_id is now populated + // and the title is persisted. thread.update_in(&mut vcx, |thread, _window, cx| { thread.push_user_content_block(None, "Hello".into(), cx); }); @@ -2705,9 +2726,11 @@ mod tests { cx.read(|cx| { let store = ThreadMetadataStore::global(cx).read(cx); assert_eq!(store.entry_ids().count(), 1); + let entry = store.entry(thread_id).expect("promoted metadata row"); + assert_eq!(entry.session_id.as_ref(), Some(&session_id)); assert_eq!( - store.entry(thread_id).unwrap().session_id.as_ref(), - Some(&session_id), + entry.title.as_ref().map(|t| t.as_ref()), + Some("Draft Thread") ); }); } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index e383a50cab2a74..989963f4baaef4 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -33,7 +33,6 @@ use ui::{ AgentThreadStatus, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, ScrollAxes, Scrollbars, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*, }; -use ui_input::ErasedEditor; use util::ResultExt; use util::paths::PathExt; use workspace::{ @@ -284,6 +283,9 @@ impl ThreadsArchiveView { let mut current_bucket: Option = None; for session in sessions { + // `query` is currently a placeholder constant; keep the filtering + // code path for when search gets wired up. + #[allow(clippy::const_is_empty)] let highlight_positions = if !query.is_empty() { let title = session .title @@ -1200,22 +1202,6 @@ impl PickerDelegate for ProjectPickerDelegate { .into() } - fn render_editor( - &self, - editor: &Arc, - window: &mut Window, - cx: &mut Context>, - ) -> Div { - h_flex() - .flex_none() - .h_9() - .px_2p5() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child(editor.render(window, cx)) - } - fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 5fdb2afb5fb3cc..d2fbdf7d4c1a28 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -3,6 +3,7 @@ mod end_trial_upsell; mod mention_crease; mod model_selector_components; mod sandbox_status_tooltip; +mod terminal_tool_header; mod undo_reject_toast; pub use agent_notification::*; @@ -10,6 +11,7 @@ pub use end_trial_upsell::*; pub use mention_crease::*; pub use model_selector_components::*; pub use sandbox_status_tooltip::*; +pub use terminal_tool_header::*; pub use undo_reject_toast::*; /// Returns the appropriate [`DocumentationSide`] for documentation asides diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 97f45526cdcee2..6959b5bc4c06c8 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -160,14 +160,14 @@ fn open_mention_uri( workspace.update(cx, |workspace, cx| match mention_uri { MentionUri::File { abs_path } => { - open_file(workspace, abs_path, None, window, cx); + open_abs_path_at_point(workspace, abs_path, None, window, cx); } MentionUri::Symbol { abs_path, line_range, .. } => { - open_file( + open_abs_path_at_point( workspace, abs_path, Some(Point::new(*line_range.start(), 0)), @@ -180,7 +180,7 @@ fn open_mention_uri( line_range, column, } => { - open_file( + open_abs_path_at_point( workspace, abs_path, Some(Point::new(*line_range.start(), column.unwrap_or(0))), @@ -339,41 +339,6 @@ fn open_skill_content_buffer( workspace.add_item(pane, Box::new(editor), None, true, true, window, cx); } -fn open_file( - workspace: &mut Workspace, - abs_path: PathBuf, - point: Option, - window: &mut Window, - cx: &mut Context, -) { - if let Some(point) = point { - if open_abs_path_at_point(workspace, abs_path.clone(), point, window, cx) { - return; - } - } - - let project = workspace.project(); - if let Some(project_path) = - project.update(cx, |project, cx| project.find_project_path(&abs_path, cx)) - { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - } else if abs_path.exists() { - workspace - .open_abs_path( - abs_path, - OpenOptions { - focus: Some(true), - ..Default::default() - }, - window, - cx, - ) - .detach_and_log_err(cx); - } -} - fn reveal_in_project_panel( workspace: &mut Workspace, abs_path: PathBuf, diff --git a/crates/agent_ui/src/ui/sandbox_status_tooltip.rs b/crates/agent_ui/src/ui/sandbox_status_tooltip.rs index 089492bca9573b..059c9dd0837e9a 100644 --- a/crates/agent_ui/src/ui/sandbox_status_tooltip.rs +++ b/crates/agent_ui/src/ui/sandbox_status_tooltip.rs @@ -8,7 +8,6 @@ use ui::{Divider, prelude::*}; pub enum SandboxRow { Message(SharedString), Path(PathBuf), - Git(PathBuf), Domain(SharedString), } @@ -21,10 +20,6 @@ impl SandboxRow { Self::Path(path.into()) } - pub fn git(path: impl Into) -> Self { - Self::Git(path.into()) - } - pub fn domain(domain: impl Into) -> Self { Self::Domain(domain.into()) } @@ -53,7 +48,6 @@ impl SandboxRow { .unwrap_or_else(|| icon_basic(IconName::Folder)); (icon, path.display().to_string()) } - SandboxRow::Git(path) => (icon_basic(IconName::GitBranch), path.display().to_string()), SandboxRow::Domain(domain) => (icon_basic(IconName::Public), domain.to_string()), }; @@ -234,17 +228,12 @@ impl Component for SandboxStatusTooltip { .group( SandboxGroup::new("Write Access").row(SandboxRow::path("/Users/you/project/build")), ) - .group(SandboxGroup::new("Network Access").row(SandboxRow::message("None"))) - .group( - SandboxGroup::new("Git Metadata Access") - .row(SandboxRow::git("/Users/you/project/.git")), - ); + .group(SandboxGroup::new("Network Access").row(SandboxRow::message("None"))); let unrestricted_section = SandboxSection::new("Defined in your settings:") - .group( - SandboxGroup::new("Write Access") - .row(SandboxRow::message("All paths (unrestricted)")), - ) + .group(SandboxGroup::new("Write Access").row(SandboxRow::message( + "All paths except protected Git metadata", + ))) .group( SandboxGroup::new("Network Access") .row(SandboxRow::message("All domains (unrestricted)")), diff --git a/crates/agent_ui/src/ui/terminal_tool_header.rs b/crates/agent_ui/src/ui/terminal_tool_header.rs new file mode 100644 index 00000000000000..f22fce6797f087 --- /dev/null +++ b/crates/agent_ui/src/ui/terminal_tool_header.rs @@ -0,0 +1,396 @@ +use std::time::Duration; + +use gpui::{AnyElement, ClickEvent, CursorStyle, Window}; +use ui::{CommonAnimationExt, Disclosure, Divider, DividerColor, Tooltip, prelude::*}; +use util::time::duration_alt_display; + +const ELAPSED_DISPLAY_THRESHOLD: Duration = Duration::from_secs(10); + +type ClickHandler = Box; + +pub struct TerminalSandboxWarning { + pub title: SharedString, + pub detail: SharedString, + pub docs_url: SharedString, +} + +#[derive(IntoElement, RegisterComponent)] +pub struct TerminalToolHeader { + id: SharedString, + hover_group: SharedString, + working_dir: SharedString, + is_expanded: bool, + elapsed: Option, + running: bool, + truncated_tooltip: Option, + failed: bool, + exit_code: Option, + sandbox_warning: Option, + on_toggle_expand: Option, + on_stop: Option, + command_slot: Option, +} + +impl TerminalToolHeader { + pub fn new( + id: impl Into, + hover_group: impl Into, + working_dir: impl Into, + is_expanded: bool, + ) -> Self { + Self { + id: id.into(), + hover_group: hover_group.into(), + working_dir: working_dir.into(), + is_expanded, + elapsed: None, + running: false, + truncated_tooltip: None, + failed: false, + exit_code: None, + sandbox_warning: None, + on_toggle_expand: None, + on_stop: None, + command_slot: None, + } + } + + pub fn elapsed(mut self, elapsed: Duration) -> Self { + self.elapsed = Some(elapsed); + self + } + + pub fn running(mut self, running: bool) -> Self { + self.running = running; + self + } + + pub fn truncated(mut self, tooltip: impl Into) -> Self { + self.truncated_tooltip = Some(tooltip.into()); + self + } + + pub fn failed(mut self, exit_code: Option) -> Self { + self.failed = true; + self.exit_code = exit_code; + self + } + + pub fn sandbox_warning(mut self, warning: TerminalSandboxWarning) -> Self { + self.sandbox_warning = Some(warning); + self + } + + pub fn on_toggle_expand( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_toggle_expand = Some(Box::new(handler)); + self + } + + pub fn on_stop( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_stop = Some(Box::new(handler)); + self + } + + pub fn command_slot(mut self, element: impl IntoElement) -> Self { + self.command_slot = Some(element.into_any_element()); + self + } +} + +impl RenderOnce for TerminalToolHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let show_elapsed = self + .elapsed + .is_some_and(|elapsed| elapsed > ELAPSED_DISPLAY_THRESHOLD); + + let Self { + id, + hover_group, + working_dir, + is_expanded, + elapsed, + running, + truncated_tooltip, + failed, + exit_code, + sandbox_warning, + on_toggle_expand, + on_stop, + command_slot, + } = self; + + let child_id = |name: &str| format!("terminal-tool-{name}-{id}"); + + let header_bg = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)); + + let header_row = h_flex() + .id(child_id("header")) + .pt_1() + .pl_1p5() + .pr_1() + .flex_none() + .gap_1() + .justify_between() + .rounded_t_md() + .child( + div().w_full().min_w_0().overflow_hidden().child( + Label::new(working_dir) + .buffer_font(cx) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate_start(), + ), + ) + .child( + Disclosure::new(child_id("disclosure"), is_expanded) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&hover_group) + .when_some(on_toggle_expand, |this, handler| this.on_click(handler)), + ) + .when(show_elapsed, |header| { + let elapsed = elapsed.unwrap_or_default(); + header.child( + Label::new(format!("({})", duration_alt_display(elapsed))) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall) + .mr_0p5() + .when(truncated_tooltip.is_some(), |s| s.mr_0()), + ) + }) + .when(running, |header| { + header + .gap_1p5() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child(Divider::vertical().color(DividerColor::Border).ml_1()) + .child( + IconButton::new(child_id("stop"), IconName::Stop) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Stop This Command", + None, + "Also possible by placing your cursor inside the terminal \ + and using regular terminal bindings.", + cx, + ) + }) + .when_some(on_stop, |this, handler| this.on_click(handler)), + ) + }) + .when_some(truncated_tooltip, |header, tooltip| { + header.child( + IconButton::new(child_id("truncated"), IconName::Info) + .cursor_style(CursorStyle::Arrow) + .style(ButtonStyle::Transparent) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(tooltip)), + ) + }) + .when(failed, |header| { + header.child( + IconButton::new(child_id("failed"), IconName::Close) + .cursor_style(CursorStyle::Arrow) + .style(ButtonStyle::Transparent) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .when_some(exit_code, |this, code| { + this.tooltip(Tooltip::text(format!("Exited with code {code}"))) + }), + ) + }) + .when_some(sandbox_warning, |header, warning| { + let TerminalSandboxWarning { + title, + detail, + docs_url, + } = warning; + header.child( + IconButton::new(child_id("sandbox-not-applied"), IconName::LockOff) + .icon_size(IconSize::Small) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + title.clone(), + None, + format!("{detail} Click to learn more about sandboxing."), + cx, + ) + }) + .on_click(move |_, _, cx| cx.open_url(&docs_url)), + ) + }); + + v_flex() + .group(hover_group) + .text_xs() + .bg(header_bg) + .child(header_row) + .children(command_slot) + } +} + +impl Component for TerminalToolHeader { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn name() -> &'static str { + "Terminal Tool Header" + } + + fn description() -> &'static str { + "The top of a terminal tool call card in the agent panel." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + let working_dir = "/Users/you/projects/zed"; + + let card = |_id: &'static str, header: TerminalToolHeader| { + v_flex() + .w_full() + .border_1() + .border_color(cx.theme().colors().border.opacity(0.6)) + .rounded_md() + .overflow_hidden() + .child( + header.command_slot( + div().px_1p5().pb_1().child( + Label::new("cargo build --release") + .buffer_font(cx) + .size(LabelSize::XSmall), + ), + ), + ) + .into_any_element() + }; + + let sandbox_warning = || TerminalSandboxWarning { + title: "Ran without sandbox".into(), + detail: "Unsandboxed execution is allowed for the rest of this thread.".into(), + docs_url: "https://zed.dev/docs/ai/sandboxing".into(), + }; + + v_flex() + .gap_4() + .child(example_group(vec![ + single_example( + "Running", + card( + "running", + TerminalToolHeader::new( + "running", + "preview-terminal-header-group-running", + working_dir, + false, + ) + .running(true), + ), + ), + single_example( + "Finished (long-running)", + card( + "elapsed", + TerminalToolHeader::new( + "elapsed", + "preview-terminal-header-group-elapsed", + working_dir, + false, + ) + .elapsed(Duration::from_secs(83)), + ), + ), + single_example( + "Truncated output", + card( + "truncated", + TerminalToolHeader::new( + "truncated", + "preview-terminal-header-group-truncated", + working_dir, + true, + ) + .truncated( + "Output is 2.5 MB long, and to avoid unexpected token \ + usage, only 16 KB was sent back to the agent.", + ), + ), + ), + single_example( + "Failed with exit code", + card( + "failed", + TerminalToolHeader::new( + "failed", + "preview-terminal-header-group-failed", + working_dir, + false, + ) + .failed(Some(101)), + ), + ), + single_example( + "Ran without sandbox", + card( + "sandbox", + TerminalToolHeader::new( + "sandbox", + "preview-terminal-header-group-sandbox", + working_dir, + false, + ) + .sandbox_warning(sandbox_warning()), + ), + ), + single_example( + "Long path (truncated from the start)", + div() + .w_80() + .child(card( + "long-path", + TerminalToolHeader::new( + "long-path", + "preview-terminal-header-group-long-path", + "/Users/you/Documents/GitHub/worktrees/some-monorepo/working-tree-three/packages/deeply/nested/service/backend/src", + false, + ), + )) + .into_any_element(), + ), + single_example( + "Everything at once", + card( + "kitchen-sink", + TerminalToolHeader::new( + "kitchen-sink", + "preview-terminal-header-group-kitchen-sink", + working_dir, + true, + ) + .elapsed(Duration::from_secs(3671)) + .truncated("Output was truncated") + .failed(Some(1)) + .sandbox_warning(sandbox_warning()), + ), + ), + ])) + .into_any_element() + } +} diff --git a/crates/agent_ui/src/unicode_confusables.rs b/crates/agent_ui/src/unicode_confusables.rs new file mode 100644 index 00000000000000..3b1ea11eb21b2c --- /dev/null +++ b/crates/agent_ui/src/unicode_confusables.rs @@ -0,0 +1,244 @@ +//! Detection of "surprising" Unicode characters in the domains and paths shown +//! in sandbox privilege-escalation prompts. +//! +//! Homoglyph/confusable attacks (a Cyrillic `а` standing in for a Latin `a`), +//! invisible characters (zero-width spaces), and bidirectional overrides can +//! make a requested domain or path look like something it is not, tricking the +//! user into granting access to the wrong target. Domains reach the prompt in +//! Punycode (`xn--…`) ASCII form, so a lookalike host is decoded back to +//! Unicode before scanning; paths are scanned as they are displayed. + +use unicode_script::UnicodeScript as _; + +/// Why a character in a domain or path is considered surprising. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SuspiciousKind { + /// A bidirectional control that can visually reorder surrounding text (for + /// example U+202E RIGHT-TO-LEFT OVERRIDE) — the classic "Trojan Source" + /// trick. + BidiControl, + /// A zero-width, invisible, or non-ASCII whitespace formatting character. + Invisible, + /// A visible non-ASCII character that can be confused with ASCII (a + /// homoglyph) or that mixes an unexpected script into otherwise-ASCII text. + Confusable, +} + +/// A single surprising character discovered while scanning a domain or path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SuspiciousChar { + pub character: char, + pub kind: SuspiciousKind, +} + +impl SuspiciousChar { + /// A human-readable, one-line description for the approval banner, such as + /// `‘а’ (U+0430 Cyrillic)` or `U+202E right-to-left override`. + pub fn description(&self) -> String { + let codepoint = format!("U+{:04X}", self.character as u32); + match self.kind { + SuspiciousKind::Confusable => { + format!( + "‘{}’ ({codepoint} {})", + self.character, + self.character.script().full_name() + ) + } + // Bidi controls and invisible characters have no meaningful glyph to + // show (and printing them could itself reorder the banner text), so + // we render only the codepoint and a name. + SuspiciousKind::BidiControl | SuspiciousKind::Invisible => { + match well_known_name(self.character) { + Some(name) => format!("{codepoint} {name}"), + None => codepoint, + } + } + } + } +} + +/// Scan a raw string for surprising Unicode characters, returning each distinct +/// offending character once, in order of first appearance. +pub fn scan(text: &str) -> Vec { + let mut result: Vec = Vec::new(); + for character in text.chars() { + if character.is_ascii() { + continue; + } + if result.iter().any(|found| found.character == character) { + continue; + } + result.push(SuspiciousChar { + character, + kind: classify(character), + }); + } + result +} + +/// Scan a host for surprising characters, first decoding any IDN/Punycode +/// (`xn--…`) labels back to Unicode so a lookalike domain that reaches us as +/// ASCII is still caught. Returns the decoded (Unicode) host — which is what the +/// banner shows the user — alongside the findings. When nothing is surprising +/// the returned host equals the input. +pub fn scan_host(host: &str) -> (String, Vec) { + // `domain_to_unicode` never fails destructively: on error it still returns a + // best-effort decoding, which is exactly what we want to scan and show. + let (decoded, _result) = idna::domain_to_unicode(host); + let findings = scan(&decoded); + (decoded, findings) +} + +fn classify(character: char) -> SuspiciousKind { + if is_bidi_control(character) { + SuspiciousKind::BidiControl + } else if is_invisible(character) { + SuspiciousKind::Invisible + } else { + SuspiciousKind::Confusable + } +} + +fn is_bidi_control(character: char) -> bool { + matches!(character, + '\u{061C}' // ARABIC LETTER MARK + | '\u{200E}' // LEFT-TO-RIGHT MARK + | '\u{200F}' // RIGHT-TO-LEFT MARK + | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO + | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI + ) +} + +fn is_invisible(character: char) -> bool { + matches!(character, + '\u{00AD}' // SOFT HYPHEN + | '\u{180E}' // MONGOLIAN VOWEL SEPARATOR + | '\u{200B}' // ZERO WIDTH SPACE + | '\u{200C}' // ZERO WIDTH NON-JOINER + | '\u{200D}' // ZERO WIDTH JOINER + | '\u{2060}' // WORD JOINER + | '\u{2061}'..='\u{2064}' // invisible math operators + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM) + ) || is_non_ascii_space(character) + // Any remaining control/format character (categories Cc/Cf) is + // invisible for our purposes. + || character.is_control() +} + +fn is_non_ascii_space(character: char) -> bool { + matches!( + character, + '\u{00A0}' // NO-BREAK SPACE + | '\u{1680}' // OGHAM SPACE MARK + | '\u{2000}' + ..='\u{200A}' // EN QUAD … HAIR SPACE + | '\u{202F}' // NARROW NO-BREAK SPACE + | '\u{205F}' // MEDIUM MATHEMATICAL SPACE + | '\u{3000}' // IDEOGRAPHIC SPACE + ) +} + +/// Friendly names for the invisible/bidi characters most likely to show up in an +/// attack, so the banner reads better than a bare codepoint. +fn well_known_name(character: char) -> Option<&'static str> { + Some(match character { + '\u{00A0}' => "no-break space", + '\u{00AD}' => "soft hyphen", + '\u{061C}' => "arabic letter mark", + '\u{180E}' => "mongolian vowel separator", + '\u{200B}' => "zero-width space", + '\u{200C}' => "zero-width non-joiner", + '\u{200D}' => "zero-width joiner", + '\u{200E}' => "left-to-right mark", + '\u{200F}' => "right-to-left mark", + '\u{202A}' => "left-to-right embedding", + '\u{202B}' => "right-to-left embedding", + '\u{202C}' => "pop directional formatting", + '\u{202D}' => "left-to-right override", + '\u{202E}' => "right-to-left override", + '\u{2060}' => "word joiner", + '\u{2066}' => "left-to-right isolate", + '\u{2067}' => "right-to-left isolate", + '\u{2068}' => "first strong isolate", + '\u{2069}' => "pop directional isolate", + '\u{3000}' => "ideographic space", + '\u{FEFF}' => "zero-width no-break space", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_ascii_is_never_flagged() { + assert!(scan("github.com").is_empty()); + assert!(scan("/home/user/project/src/main.rs").is_empty()); + assert!(scan("*.npmjs.org").is_empty()); + } + + #[test] + fn detects_cyrillic_homoglyph() { + // "gіthub.com" with a Cyrillic "і" (U+0456). + let findings = scan("g\u{0456}thub.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0456}'); + assert_eq!(findings[0].kind, SuspiciousKind::Confusable); + assert!(findings[0].description().contains("U+0456")); + assert!(findings[0].description().contains("Cyrillic")); + } + + #[test] + fn detects_bidi_override() { + let findings = scan("safe\u{202E}txt.exe"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, SuspiciousKind::BidiControl); + assert_eq!(findings[0].description(), "U+202E right-to-left override"); + } + + #[test] + fn detects_zero_width_space() { + let findings = scan("git\u{200B}hub.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, SuspiciousKind::Invisible); + assert_eq!(findings[0].description(), "U+200B zero-width space"); + } + + #[test] + fn deduplicates_repeated_characters() { + // Two Cyrillic "а" (U+0430) should be reported once. + let findings = scan("\u{0430}bc\u{0430}"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn scan_host_decodes_punycode_lookalike() { + // "аpple.com" (leading Cyrillic а, U+0430) encodes to this Punycode. + let (decoded, findings) = scan_host("xn--pple-43d.com"); + assert_eq!(decoded, "\u{0430}pple.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0430}'); + assert_eq!(findings[0].kind, SuspiciousKind::Confusable); + } + + #[test] + fn scan_host_leaves_plain_domains_alone() { + let (decoded, findings) = scan_host("github.com"); + assert_eq!(decoded, "github.com"); + assert!(findings.is_empty()); + } + + #[test] + fn scan_host_handles_wildcard_subdomain_patterns() { + // Host patterns can carry a leading `*.` wildcard; decoding must not + // choke on it, and a lookalike label behind it is still caught. + let (_decoded, findings) = scan_host("*.xn--pple-43d.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0430}'); + + let (decoded, findings) = scan_host("*.github.com"); + assert_eq!(decoded, "*.github.com"); + assert!(findings.is_empty()); + } +} diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 676b43b02b7c2c..cf8d547cc3e364 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -19,7 +19,14 @@ pub mod batches; pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; -const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; +pub const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; + +pub fn supports_fast_mode(model_id: &str) -> bool { + matches!( + model_id, + "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" + ) +} pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; @@ -156,10 +163,7 @@ impl Model { AnthropicModelMode::Default }; - let supports_speed = matches!( - entry.id.as_str(), - "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" - ); + let supports_speed = supports_fast_mode(&entry.id); // let supports_compaction = matches!( diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 7c9a8c53695ca3..884ed1a1ddde9b 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -3,9 +3,9 @@ use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, Role, StopReason, - TokenUsage, + LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, + LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; @@ -68,7 +68,7 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca } } -fn to_anthropic_content(content: MessageContent) -> Option { +fn to_anthropic_content(content: MessageContent) -> Result> { match content { MessageContent::Text(text) => { let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) { @@ -77,12 +77,12 @@ fn to_anthropic_content(content: MessageContent) -> Option { text }; if !text.is_empty() { - Some(RequestContent::Text { + Ok(Some(RequestContent::Text { text, cache_control: None, - }) + })) } else { - None + Ok(None) } } MessageContent::Thinking { @@ -92,36 +92,41 @@ fn to_anthropic_content(content: MessageContent) -> Option { if let Some(signature) = signature && !thinking.is_empty() { - Some(RequestContent::Thinking { + Ok(Some(RequestContent::Thinking { thinking, signature, cache_control: None, - }) + })) } else { - None + Ok(None) } } MessageContent::RedactedThinking(data) => { if !data.is_empty() { - Some(RequestContent::RedactedThinking { data }) + Ok(Some(RequestContent::RedactedThinking { data })) } else { - None + Ok(None) } } - MessageContent::Image(image) => Some(RequestContent::Image { + MessageContent::Image(image) => Ok(Some(RequestContent::Image { source: ImageSource { source_type: "base64".to_string(), media_type: "image/png".to_string(), data: image.source.to_string(), }, cache_control: None, - }), - MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse { - id: tool_use.id.to_string(), - name: tool_use.name.to_string(), - input: tool_use.input, - cache_control: None, - }), + })), + MessageContent::ToolUse(tool_use) => match tool_use.input { + LanguageModelToolUseInput::Json(input) => Ok(Some(RequestContent::ToolUse { + id: tool_use.id.to_string(), + name: tool_use.name.to_string(), + input, + cache_control: None, + })), + LanguageModelToolUseInput::Text(_) => Err(anyhow::anyhow!( + "Anthropic does not support custom tool calls" + )), + }, MessageContent::ToolResult(tool_result) => { let content = match tool_result.content.as_slice() { [LanguageModelToolResultContent::Text(text)] => { @@ -147,24 +152,24 @@ fn to_anthropic_content(content: MessageContent) -> Option { ToolResultContent::Multipart(parts) } }; - Some(RequestContent::ToolResult { + Ok(Some(RequestContent::ToolResult { tool_use_id: tool_result.tool_use_id.to_string(), is_error: tool_result.is_error, content, cache_control: None, - }) + })) } MessageContent::Compaction(CompactionContent::Summary { content }) => { - Some(RequestContent::Compaction { + Ok(Some(RequestContent::Compaction { content, cache_control: None, - }) + })) } // Encrypted compaction blocks come from other providers, and a // Pending block is a streaming-only UI signal; neither is replayed. MessageContent::Compaction( CompactionContent::Encrypted { .. } | CompactionContent::Pending, - ) => None, + ) => Ok(None), } } @@ -175,7 +180,7 @@ pub fn into_anthropic( max_output_tokens: u64, mode: AnthropicModelMode, cache_mode: AnthropicPromptCacheMode, -) -> crate::Request { +) -> Result { let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); let mut any_message_wants_cache = false; @@ -189,11 +194,12 @@ pub fn into_anthropic( match message.role { Role::User | Role::Assistant => { - let mut anthropic_message_content: Vec = message - .content - .into_iter() - .filter_map(to_anthropic_content) - .collect(); + let mut anthropic_message_content = Vec::new(); + for content in message.content { + if let Some(content) = to_anthropic_content(content)? { + anthropic_message_content.push(content); + } + } let anthropic_role = match message.role { Role::User => crate::Role::User, Role::Assistant => crate::Role::Assistant, @@ -261,21 +267,29 @@ pub fn into_anthropic( let mut tools: Vec = request .tools .into_iter() - .map(|tool| Tool { - name: tool.name, - description: tool.description, - input_schema: tool.input_schema, - eager_input_streaming: tool.use_input_streaming, - cache_control: None, + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { + input_schema, + use_input_streaming, + } => Ok(Tool { + name: tool.name, + description: tool.description, + input_schema, + eager_input_streaming: use_input_streaming, + cache_control: None, + }), + LanguageModelRequestToolInput::Custom { .. } => { + Err(anyhow::anyhow!("Anthropic does not support custom tools")) + } }) - .collect(); + .collect::>()?; if let Some(cache_control) = long_lived_cache && let Some(last_tool) = tools.last_mut() { last_tool.cache_control = Some(cache_control); } - crate::Request { + Ok(crate::Request { model, messages: new_messages, max_tokens: max_output_tokens, @@ -339,7 +353,7 @@ pub fn into_anthropic( trigger: Some(CompactionTrigger::InputTokens { value }), }], }), - } + }) } pub struct AnthropicEventMapper { @@ -451,7 +465,7 @@ impl AnthropicEventMapper { name: tool_use.name.clone().into(), is_input_complete: false, raw_input: tool_use.input_json.clone(), - input, + input: LanguageModelToolUseInput::Json(input), thought_signature: None, }, ))]; @@ -469,7 +483,7 @@ impl AnthropicEventMapper { id: tool_use.id.into(), name: tool_use.name.into(), is_input_complete: true, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: tool_use.input_json.clone(), thought_signature: None, }, @@ -597,12 +611,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -617,7 +631,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); // No message content block should carry cache_control anymore; the // conversation breakpoint is set via top-level automatic caching. @@ -703,12 +718,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -723,7 +738,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Legacy, - ); + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -781,7 +797,8 @@ mod tests { 128_000, AnthropicModelMode::AdaptiveThinking, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); assert_eq!( anthropic_request @@ -813,12 +830,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -833,7 +850,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -879,6 +897,7 @@ mod tests { }, AnthropicPromptCacheMode::Automatic, ) + .unwrap() } #[test] @@ -977,7 +996,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Disabled, - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&anthropic_request.context_management).unwrap(), diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 298d1a736959d1..671ab34df25080 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -22,6 +22,9 @@ tempfile.workspace = true util.workspace = true zeroize.workspace = true +[target.'cfg(not(target_os = "windows"))'.dependencies] +which.workspace = true + [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index e887841b584914..8092e36f3fa5f5 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -91,6 +91,9 @@ pub struct AskPassSession { #[cfg(not(target_os = "windows"))] const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(not(target_os = "windows"))] +const GPG_WRAPPER_SCRIPT_NAME: &str = "gpg-wrapper.sh"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. @@ -153,15 +156,25 @@ impl AskPassSession { // The caller is responsible for examining the result of their own commands and cancelling this // future when this is no longer needed. Note that this can only be called once, but due to the // drop order this takes an &mut, so you can `drop()` it after you're done with the master process. - pub async fn run(&mut self) -> AskPassResult { - // This is the default timeout setting used by VSCode. - let connection_timeout = Duration::from_secs(17); + // + // When `timeout` is provided, this resolves with `AskPassResult::Timedout` if no askpass prompt + // has been opened within that duration. This is intended for connection establishment (e.g. + // SSH), where "no prompt and no connection" indicates an unreachable host. Callers wrapping + // commands that may legitimately run for a long time without prompting (e.g. git) should pass + // `None` and rely on the command's own completion instead. + pub async fn run(&mut self, timeout: Option) -> AskPassResult { let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once"); let askpass_kill_master_rx = self .askpass_kill_master_rx .take() .expect("Only call run once"); let executor = self.executor.clone(); + let timer = async move { + match timeout { + Some(timeout) => executor.timer(timeout).await, + None => std::future::pending().await, + } + }; select_biased! { _ = askpass_opened_rx.fuse() => { @@ -170,7 +183,7 @@ impl AskPassSession { AskPassResult::CancelledByUser } - _ = futures::FutureExt::fuse(executor.timer(connection_timeout)) => { + _ = futures::FutureExt::fuse(timer) => { AskPassResult::Timedout } } @@ -189,6 +202,15 @@ impl AskPassSession { self.askpass_task.script_path() } + /// Path to a script suitable for git's `gpg.program`, routing GnuPG + /// passphrase prompts through Zed's askpass UI. `None` if unavailable. + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + #[cfg(not(target_os = "windows"))] + return self.askpass_task.gpg_wrapper_path(); + #[cfg(target_os = "windows")] + return None; + } + /// Returns the socket path to set as ZED_ASKPASS_SOCKET. /// /// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only @@ -206,6 +228,8 @@ pub struct PasswordProxy { /// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS). /// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed). askpass_script_path: std::path::PathBuf, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path: Option, /// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET /// so cli.exe can find it without --askpass argument parsing. #[cfg(target_os = "windows")] @@ -238,6 +262,24 @@ impl PasswordProxy { let askpass_socket_path = askpass_socket.clone(); + // Create a gpg wrapper script that routes GnuPG passphrase prompts through + // the same socket (and thus through Zed's askpass UI). This only works on + // Unix where we control the pinentry via loopback mode. We compute the path + // before the socket task takes ownership of `temp_dir`, and write the file + // afterwards. + #[cfg(not(target_os = "windows"))] + let (gpg_wrapper_script_path, gpg_wrapper_script) = + match generate_gpg_wrapper_script(askpass_program, &askpass_socket_path) { + Ok(script) => ( + Some(temp_dir.path().join(GPG_WRAPPER_SCRIPT_NAME)), + Some(script), + ), + Err(err) => { + log::warn!("could not create gpg askpass wrapper: {err:#}"); + (None, None) + } + }; + let _task = executor.spawn(async move { maybe!(async move { let listener = @@ -291,9 +333,36 @@ impl PasswordProxy { })?; } + // Write the gpg wrapper script (computed above) and mark it executable. + #[cfg(not(target_os = "windows"))] + let gpg_wrapper_script_path = + if let Some((path, script)) = gpg_wrapper_script_path.zip(gpg_wrapper_script) { + match async { + fs::write(&path, script) + .await + .with_context(|| format!("creating gpg wrapper script at {path:?}"))?; + make_file_executable(&path).await.with_context(|| { + format!("marking gpg wrapper script executable at {path:?}") + })?; + anyhow::Ok(()) + } + .await + { + Ok(()) => Some(path), + Err(err) => { + log::warn!("could not write gpg askpass wrapper: {err:#}"); + None + } + } + } else { + None + }; + Ok(Self { _task, askpass_script_path, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path, #[cfg(target_os = "windows")] askpass_socket_path, }) @@ -307,6 +376,11 @@ impl PasswordProxy { pub fn socket_path(&self) -> impl AsRef { &self.askpass_socket_path } + + #[cfg(not(target_os = "windows"))] + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + self.gpg_wrapper_script_path.as_deref() + } } /// Runs Zed in netcat mode for use in askpass. @@ -398,3 +472,99 @@ fn generate_askpass_script( "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", )) } + +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_gpg_wrapper_script( + askpass_program: &std::path::Path, + askpass_socket: &std::path::Path, +) -> Result { + let shell_kind = ShellKind::Posix; + let gpg_program = find_gpg_program().context("could not find a gpg binary on PATH")?; + let gpg_program = gpg_program + .to_str() + .context("gpg program is on a non-utf8 path")?; + let gpg_program = shell_kind + .try_quote_prefix_aware(gpg_program) + .context("Failed to shell-escape gpg program path")?; + + let askpass_program = shell_kind.prepend_command_prefix( + askpass_program + .to_str() + .context("Askpass program is on a non-utf8 path")?, + ); + let askpass_program = shell_kind + .try_quote_prefix_aware(&askpass_program) + .context("Failed to shell-escape Askpass program path")?; + let askpass_socket = askpass_socket + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass socket path")?; + + let prompt = shell_kind + .try_quote_prefix_aware("Enter passphrase for your Git signing key:") + .context("Failed to shell-escape gpg passphrase prompt")?; + + // The wrapper only intervenes when git asks gpg to *sign* (e.g. `gpg -bsau + // `); other invocations like `--verify` run unchanged. For signing we + // first try plain gpg so gpg-agent/keychain can supply a cached or empty + // passphrase silently, and only fall back to asking Zed (loopback mode, fd + // 3) when that fails, e.g. the "Inappropriate ioctl for device" case with no + // TTY for pinentry. + // + // git streams the payload on stdin (readable once) and reads the signature + // from stdout, so we buffer stdin to replay it into both attempts and buffer + // the first attempt's output, forwarding it only if it succeeds. The + // passphrase goes to fd 3 via a pipe. + Ok(format!( + r#"#!/bin/sh +for arg in "$@"; do + case "$arg" in + # Long-form signing options. + --sign|--detach-sign|--clearsign|--clear-sign) is_signing=1 ;; + # Skip other long options so flags like `--status-fd` don't match below. + --*) ;; + # Short-flag clusters containing `s`, e.g. git's `-bsau`. + -*s*) is_signing=1 ;; + esac +done + +# Not a signing request: run gpg as-is, leaving its prompting untouched. +if [ -z "${{is_signing}}" ]; then + exec {gpg_program} "$@" +fi + +# Signing. Buffer stdin (the payload) and the first attempt's output +# so we can retry cleanly on failure without git seeing partial output. +tmpdir=$(mktemp -d) || exit 1 +trap 'rm -rf "$tmpdir"' EXIT +payload="$tmpdir/payload" +signature="$tmpdir/signature" +status="$tmpdir/status" +cat > "$payload" || exit 1 + +# First try letting gpg-agent/keychain supply the passphrase without any +# interactive pinentry. If that succeeds (cached passphrase) +# forward its output and we're done, so Zed never shows a modal. +if {gpg_program} --pinentry-mode error "$@" < "$payload" > "$signature" 2> "$status"; then + cat "$status" >&2 + cat "$signature" + exit 0 +fi + +# The silent attempt failed: ask Zed for the passphrase, then hand it to gpg on +# fd 3 using loopback mode so no pinentry/terminal is required. +passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null) +printf '%s\n' "$passphrase" | +{gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3<&0 < "$payload" +"#, + )) +} + +/// Finds the real `gpg` (or `gpg2`) executable on `PATH`. +#[inline] +#[cfg(not(target_os = "windows"))] +fn find_gpg_program() -> Option { + ["gpg", "gpg2"] + .into_iter() + .find_map(|candidate| which::which(candidate).ok()) +} diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 47b4f7a0380e10..a9799cadaf7070 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -37,6 +37,7 @@ which.workspace = true [dev-dependencies] ctor.workspace = true clock= { workspace = true, "features" = ["test-support"] } +db = { workspace = true, features = ["test-support"] } futures.workspace = true gpui = { workspace = true, "features" = ["test-support"] } parking_lot.workspace = true diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 9786aa84d1622f..1916ef6dc57769 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -13,7 +13,10 @@ use semver::Version; use serde::{Deserialize, Serialize}; use settings::{RegisterSetting, Settings, SettingsStore}; use smol::fs::File; -use smol::{fs, io::AsyncReadExt}; +use smol::{ + fs, + io::{AsyncReadExt, AsyncWriteExt}, +}; use std::mem; use std::{ env::{ @@ -106,12 +109,6 @@ actions!( ] ); -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum VersionCheckType { - Sha(AppCommitSha), - Semantic(Version), -} - #[derive(Serialize, Debug)] pub struct AssetQuery<'a> { asset: &'a str, @@ -126,20 +123,33 @@ pub struct AssetQuery<'a> { pub enum AutoUpdateStatus { Idle, Checking, - Downloading { version: VersionCheckType }, - Installing { version: VersionCheckType }, - Updated { version: VersionCheckType }, - Errored { error: Arc }, + Downloading { + version: Version, + /// Download progress as a fraction in the range `0.0..=1.0`, or `None` + /// when the total download size is not yet known. + progress: Option, + }, + Installing { + version: Version, + }, + Updated { + version: Version, + }, + Errored { + error: Arc, + }, } impl PartialEq for AutoUpdateStatus { + // `progress` is deliberately not compared: two `Downloading` statuses for + // the same version are equal regardless of how far the download is. fn eq(&self, other: &Self) -> bool { match (self, other) { (AutoUpdateStatus::Idle, AutoUpdateStatus::Idle) => true, (AutoUpdateStatus::Checking, AutoUpdateStatus::Checking) => true, ( - AutoUpdateStatus::Downloading { version: v1 }, - AutoUpdateStatus::Downloading { version: v2 }, + AutoUpdateStatus::Downloading { version: v1, .. }, + AutoUpdateStatus::Downloading { version: v2, .. }, ) => v1 == v2, ( AutoUpdateStatus::Installing { version: v1 }, @@ -170,6 +180,7 @@ pub struct AutoUpdater { pending_poll: Option>>, quit_subscription: Option, update_check_type: UpdateCheckType, + dismissed_status: Option, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -183,35 +194,53 @@ struct MacOsUnmounter<'a> { background_executor: &'a BackgroundExecutor, } +impl MacOsUnmounter<'_> { + /// Unmounts the disk image and waits for completion. This must happen + /// before the `InstallerDir` is dropped: deleting the temp dir while the + /// image is still mounted inside it fails silently and leaks the + /// directory (and the downloaded DMG) in the system temp dir. + async fn unmount(mut self) { + let mount_path = mem::take(&mut self.mount_path); + unmount_disk_image(&mount_path).await; + } +} + impl Drop for MacOsUnmounter<'_> { fn drop(&mut self) { let mount_path = mem::take(&mut self.mount_path); + // Safety net for early exits and cancellation; the happy path calls + // `unmount`, which leaves the path empty. + if mount_path.as_os_str().is_empty() { + return; + } self.background_executor - .spawn(async move { - let unmount_output = new_command("hdiutil") - .args(["detach", "-force"]) - .arg(&mount_path) - .output() - .await; - match unmount_output { - Ok(output) if output.status.success() => { - log::info!("Successfully unmounted the disk image"); - } - Ok(output) => { - log::error!( - "Failed to unmount disk image: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - } - Err(error) => { - log::error!("Error while trying to unmount disk image: {:?}", error); - } - } - }) + .spawn(async move { unmount_disk_image(&mount_path).await }) .detach(); } } +async fn unmount_disk_image(mount_path: &Path) { + let unmount_output = new_command("hdiutil") + .args(["detach", "-force"]) + .arg(mount_path) + .output() + .await; + match unmount_output { + Ok(output) if output.status.success() => { + log::info!("Successfully unmounted the disk image"); + } + Ok(output) => { + log::error!( + "Failed to unmount disk image: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(error) => { + log::error!("Error while trying to unmount disk image: {:?}", error); + } + } +} + #[derive(Clone, Copy, Debug, RegisterSetting)] struct AutoUpdateSetting(bool); @@ -334,6 +363,9 @@ pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> { None } +#[cfg(not(target_os = "windows"))] +const INSTALLER_DIR_PREFIX: &str = "zed-auto-update"; + #[cfg(not(target_os = "windows"))] struct InstallerDir(tempfile::TempDir); @@ -342,7 +374,7 @@ impl InstallerDir { async fn new() -> Result { Ok(Self( tempfile::Builder::new() - .prefix("zed-auto-update") + .prefix(INSTALLER_DIR_PREFIX) .tempdir()?, )) } @@ -416,6 +448,7 @@ impl AutoUpdater { pending_poll: None, quit_subscription, update_check_type: UpdateCheckType::Automatic, + dismissed_status: None, } } @@ -436,6 +469,9 @@ impl AutoUpdater { .log_err(); } + #[cfg(all(not(target_os = "windows"), not(test)))] + cx.background_spawn(cleanup_stale_installer_dirs()).detach(); + loop { this.update(cx, |this, cx| this.poll(UpdateCheckType::Automatic, cx))?; cx.background_executor().timer(poll_interval).await; @@ -448,6 +484,9 @@ impl AutoUpdater { } pub fn poll(&mut self, check_type: UpdateCheckType, cx: &mut Context) { + if check_type.is_manual() { + self.dismissed_status = None; + } if self.pending_poll.is_some() { if self.update_check_type == UpdateCheckType::Automatic { self.update_check_type = check_type; @@ -501,6 +540,15 @@ impl AutoUpdater { self.status.clone() } + pub fn dismissed_status(&self) -> Option { + self.dismissed_status.clone() + } + + pub fn dismiss_status(&mut self, status: AutoUpdateStatus, cx: &mut Context) { + self.dismissed_status = Some(status); + cx.notify(); + } + pub fn dismiss(&mut self, cx: &mut Context) -> bool { if let AutoUpdateStatus::Idle = self.status { return false; @@ -700,6 +748,7 @@ impl AutoUpdater { this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Downloading { version: newer_version.clone(), + progress: None, }; cx.notify(); }); @@ -708,9 +757,27 @@ impl AutoUpdater { .await .context("Failed to create installer dir")?; let target_path = Self::target_path(&installer_dir).await?; - download_release(&target_path, fetched_release_data, client) - .await - .with_context(|| format!("Failed to download update to {}", target_path.display()))?; + let progress_entity = this.clone(); + let mut progress_cx = cx.clone(); + download_release( + &target_path, + fetched_release_data, + client, + move |progress| { + progress_entity.update(&mut progress_cx, |this, cx| { + if let AutoUpdateStatus::Downloading { + progress: current_progress, + .. + } = &mut this.status + { + *current_progress = progress; + cx.notify(); + } + }); + }, + ) + .await + .with_context(|| format!("Failed to download update to {}", target_path.display()))?; this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Installing { @@ -765,49 +832,33 @@ impl AutoUpdater { installed_version: Version, fetched_version: String, status: AutoUpdateStatus, - ) -> Result> { - let parsed_fetched_version = fetched_version.parse::(); - - if let AutoUpdateStatus::Updated { version, .. } = status { - match version { - VersionCheckType::Sha(cached_version) => { - let should_download = - parsed_fetched_version.as_ref().ok().is_none_or(|version| { - version.build.as_str().rsplit('.').next() - != Some(&cached_version.full()) - }); - let newer_version = should_download - .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); - return Ok(newer_version); - } - VersionCheckType::Semantic(cached_version) => { - return Self::check_if_fetched_version_is_newer_non_nightly( - cached_version, - parsed_fetched_version?, - ); - } - } - } + ) -> Result> { + let fetched_version = fetched_version.parse::()?; match release_channel { ReleaseChannel::Nightly => { - let should_download = app_commit_sha - .ok() - .flatten() - .map(|sha| { - parsed_fetched_version.as_ref().ok().is_none_or(|version| { - version.build.as_str().rsplit('.').next() != Some(&sha) - }) - }) - .unwrap_or(true); - let newer_version = should_download - .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); - Ok(newer_version) + let should_download = if let AutoUpdateStatus::Updated { version } = status { + fetched_version != version + } else { + let fetched_sha = fetched_version.build.as_str().rsplit('.').next(); + app_commit_sha + .ok() + .flatten() + .is_none_or(|sha| fetched_sha != Some(sha.as_str())) + }; + Ok(should_download.then_some(fetched_version)) + } + _ => { + let current_version = if let AutoUpdateStatus::Updated { version } = status { + version + } else { + installed_version + }; + Ok(Self::check_if_fetched_version_is_newer_non_nightly( + current_version, + fetched_version, + )) } - _ => Self::check_if_fetched_version_is_newer_non_nightly( - installed_version, - parsed_fetched_version?, - ), } } @@ -870,13 +921,11 @@ impl AutoUpdater { fn check_if_fetched_version_is_newer_non_nightly( mut installed_version: Version, fetched_version: Version, - ) -> Result> { + ) -> Option { // For non-nightly releases, ignore build and pre-release fields as they're not provided by our endpoints right now. installed_version.pre = semver::Prerelease::EMPTY; installed_version.build = semver::BuildMetadata::EMPTY; - let should_download = fetched_version > installed_version; - let newer_version = should_download.then(|| VersionCheckType::Semantic(fetched_version)); - Ok(newer_version) + (fetched_version > installed_version).then_some(fetched_version) } pub fn set_should_show_update_notification( @@ -989,6 +1038,7 @@ async fn download_release( target_path: &Path, release: ReleaseAsset, client: Arc, + mut on_progress: impl FnMut(Option), ) -> Result<()> { let mut target_file = File::create(&target_path).await?; @@ -998,7 +1048,40 @@ async fn download_release( "failed to download update: {:?}", response.status() ); - smol::io::copy(response.body_mut(), &mut target_file).await?; + + let total_bytes = response + .headers() + .get(http_client::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|total_bytes| *total_bytes > 0); + + let mut downloaded_bytes: u64 = 0; + let mut last_reported_percent: Option = None; + let mut buffer = [0u8; 8192]; + let body = response.body_mut(); + loop { + let bytes_read = body.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + target_file.write_all(&buffer[..bytes_read]).await?; + downloaded_bytes += bytes_read as u64; + + if let Some(total_bytes) = total_bytes { + let fraction = (downloaded_bytes as f32 / total_bytes as f32).clamp(0.0, 1.0); + // Only report when the whole-number percentage changes to avoid notifying the UI on every chunk. + let percent = (fraction * 100.0) as u8; + if last_reported_percent != Some(percent) { + last_reported_percent = Some(percent); + on_progress(Some(fraction)); + } + } + } + target_file.flush().await?; + if total_bytes.is_some() && last_reported_percent != Some(100) { + on_progress(Some(1.0)); + } log::info!("downloaded update. path:{:?}", target_path); Ok(()) @@ -1102,8 +1185,7 @@ async fn install_release_macos( String::from_utf8_lossy(&output.stderr) ); - // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits - let _unmounter = MacOsUnmounter { + let unmounter = MacOsUnmounter { mount_path: mount_path.clone(), background_executor, }; @@ -1112,10 +1194,13 @@ async fn install_release_macos( cmd.args(["-av", "--delete", "--exclude", "Icon?"]) .arg(&mounted_app_path) .arg(&running_app_path); - let output = cmd - .output() - .await - .with_context(|| "failed to rsync: {cmd}")?; + let rsync_output = cmd.output().await; + + // Await the unmount (even if rsync failed) so that the installer temp dir + // can be deleted once this function returns. + unmounter.unmount().await; + + let output = rsync_output.with_context(|| "failed to rsync: {cmd}")?; anyhow::ensure!( output.status.success(), @@ -1126,6 +1211,52 @@ async fn install_release_macos( Ok(None) } +/// Removes stale installer dirs from the system temp dir. Older Zed versions +/// leaked one per update by deleting the dir while the downloaded disk image +/// was still mounted inside it, which made the deletion fail silently. +#[cfg(any(rust_analyzer, all(not(target_os = "windows"), not(test))))] +async fn cleanup_stale_installer_dirs() { + const STALE_INSTALLER_DIR_AGE: Duration = Duration::from_secs(24 * 60 * 60); + + let temp_dir = std::env::temp_dir(); + let Ok(mut entries) = fs::read_dir(&temp_dir).await else { + log::warn!("failed to read temp dir {temp_dir:?} while cleaning up installer dirs"); + return; + }; + while let Some(entry) = entries.next().await { + let Ok(entry) = entry else { + continue; + }; + if !entry + .file_name() + .to_string_lossy() + .starts_with(INSTALLER_DIR_PREFIX) + { + continue; + } + // Leave recent dirs alone, as they may belong to an update currently + // in progress in another Zed instance. + let is_stale = entry.metadata().await.ok().is_some_and(|metadata| { + metadata.is_dir() + && metadata.modified().ok().is_some_and(|modified| { + SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age > STALE_INSTALLER_DIR_AGE) + }) + }); + if is_stale { + if let Err(error) = fs::remove_dir_all(entry.path()).await { + log::warn!( + "failed to remove stale installer dir {:?}: {error}", + entry.path() + ); + } else { + log::info!("removed stale installer dir {:?}", entry.path()); + } + } + } +} + async fn cleanup_windows() -> Result<()> { let parent = std::env::current_exe()? .parent() @@ -1282,8 +1413,12 @@ mod tests { }); release_available.store(true, atomic::Ordering::SeqCst); - cx.background_executor.advance_clock(POLL_INTERVAL); - cx.background_executor.run_until_parked(); + // Background polling is intentionally disabled in Superzed + // (`ReleaseChannel::poll_for_updates` is always false), so trigger the + // update check directly instead of advancing past POLL_INTERVAL. + auto_updater.update(cx, |updater, cx| { + updater.poll(UpdateCheckType::Automatic, cx) + }); loop { cx.background_executor.timer(Duration::from_millis(0)).await; @@ -1297,7 +1432,8 @@ mod tests { assert_eq!( status, AutoUpdateStatus::Downloading { - version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + version: semver::Version::new(0, 100, 1), + progress: None, } ); @@ -1327,7 +1463,7 @@ mod tests { assert_eq!( status, AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + version: semver::Version::new(0, 100, 1) } ); let will_restart = cx.expect_restart(); @@ -1337,6 +1473,114 @@ mod tests { assert_eq!(std::fs::read_to_string(path).unwrap(), ""); } + #[gpui::test] + async fn test_download_release_reports_progress(cx: &mut TestAppContext) { + cx.background_executor.allow_parking(); + + let body = vec![0u8; 20_000]; + let content_length = body.len(); + + let client = FakeHttpClient::create(move |_req| { + let body = body.clone(); + async move { + Ok(Response::builder() + .status(200) + .header( + http_client::http::header::CONTENT_LENGTH, + body.len().to_string(), + ) + .body(body.into()) + .unwrap()) + } + }); + + let temp_dir = tempdir().unwrap(); + let target_path = temp_dir.path().join("zed-download"); + let release = ReleaseAsset { + version: "1.0.0".to_string(), + url: "https://test.example/download".to_string(), + }; + + let reported = Rc::new(std::cell::RefCell::new(Vec::::new())); + download_release(&target_path, release, client, { + let reported = reported.clone(); + move |fraction| { + if let Some(fraction) = fraction { + reported.borrow_mut().push(fraction); + } + } + }) + .await + .unwrap(); + + let reported = reported.borrow(); + assert!( + reported.len() >= 2, + "expected progress to be reported across multiple reads, got {reported:?}" + ); + assert_eq!( + reported.last().copied(), + Some(1.0), + "download should finish at 100%" + ); + for fraction in reported.iter() { + assert!( + (0.0..=1.0).contains(fraction), + "progress {fraction} out of range" + ); + } + for pair in reported.windows(2) { + assert!( + pair[0] <= pair[1], + "progress must not decrease: {reported:?}" + ); + } + + let downloaded_len = std::fs::metadata(&target_path).unwrap().len(); + assert_eq!(downloaded_len, content_length as u64); + } + + #[gpui::test] + async fn test_download_release_without_content_length_reports_no_progress( + cx: &mut TestAppContext, + ) { + cx.background_executor.allow_parking(); + + let body = vec![0u8; 20_000]; + let content_length = body.len(); + + let client = FakeHttpClient::create(move |_req| { + let body = body.clone(); + async move { Ok(Response::builder().status(200).body(body.into()).unwrap()) } + }); + + let temp_dir = tempdir().unwrap(); + let target_path = temp_dir.path().join("zed-download"); + let release = ReleaseAsset { + version: "1.0.0".to_string(), + url: "https://test.example/download".to_string(), + }; + + let reported = Rc::new(std::cell::RefCell::new(Vec::>::new())); + download_release(&target_path, release, client, { + let reported = reported.clone(); + move |fraction| { + reported.borrow_mut().push(fraction); + } + }) + .await + .unwrap(); + + assert!( + reported.borrow().is_empty(), + "progress should not be reported when the total size is unknown, got {:?}", + reported.borrow() + ); + + let downloaded_len = std::fs::metadata(&target_path).unwrap().len(); + assert_eq!(downloaded_len, content_length as u64); + } + #[test] fn test_stable_does_not_update_when_fetched_version_is_not_higher() { let release_channel = ReleaseChannel::Stable; @@ -1372,10 +1616,7 @@ mod tests { status, ); - assert_eq!( - newer_version.unwrap(), - Some(VersionCheckType::Semantic(fetched_version)) - ); + assert_eq!(newer_version.unwrap(), Some(fetched_version)); } #[test] @@ -1384,7 +1625,7 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), + version: semver::Version::new(1, 0, 1), }; let fetched_version = semver::Version::new(1, 0, 1); @@ -1405,7 +1646,7 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), + version: semver::Version::new(1, 0, 1), }; let fetched_version = semver::Version::new(1, 0, 2); @@ -1417,10 +1658,7 @@ mod tests { status, ); - assert_eq!( - newer_version.unwrap(), - Some(VersionCheckType::Semantic(fetched_version)) - ); + assert_eq!(newer_version.unwrap(), Some(fetched_version)); } #[test] @@ -1430,13 +1668,13 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Idle; - let fetched_sha = "1.0.0+a".to_string(); + let fetched_version = "1.0.0+a".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1449,19 +1687,19 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_sha = "b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } @@ -1472,15 +1710,15 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1494,22 +1732,51 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+c".to_string(); + let fetched_version = "1.0.0+c".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) + ); + } + + #[test] + fn test_nightly_does_not_redownload_after_updating_to_fetched_version() { + let release_channel = ReleaseChannel::Nightly; + let installed_version = semver::Version::new(1, 0, 0); + let fetched_version = "1.0.0+nightly.b".to_string(); + + let newer_version = AutoUpdater::check_if_fetched_version_is_newer( + release_channel, + Ok(Some("a".to_string())), + installed_version.clone(), + fetched_version.clone(), + AutoUpdateStatus::Idle, + ) + .unwrap() + .expect("a newer nightly version should be available"); + + let next_check = AutoUpdater::check_if_fetched_version_is_newer( + release_channel, + Ok(Some("a".to_string())), + installed_version, + fetched_version, + AutoUpdateStatus::Updated { + version: newer_version, + }, ); + + assert_eq!(next_check.unwrap(), None); } #[test] @@ -1518,19 +1785,19 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_sha = "a".to_string(); + let fetched_version = "1.0.0+a".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } @@ -1541,15 +1808,15 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1563,21 +1830,21 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "c".to_string(); + let fetched_version = "1.0.0+c".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } } diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index a2f299a3c9ed10..9b8a25964018ca 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -46,39 +46,29 @@ pub struct BedrockModelCacheConfiguration { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] -pub enum Model { +pub enum ConverseModel { // Anthropic Claude 4+ models - #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] - ClaudeHaiku4_5, #[serde( - rename = "claude-sonnet-4", - alias = "claude-sonnet-4-latest", - alias = "claude-sonnet-4-thinking", - alias = "claude-sonnet-4-thinking-latest" + rename = "claude-fable-5", + alias = "claude-fable-5-latest", + alias = "claude-fable-5-thinking", + alias = "claude-fable-5-thinking-latest" )] - ClaudeSonnet4, - #[default] + ClaudeFable5, #[serde( - rename = "claude-sonnet-4-5", - alias = "claude-sonnet-4-5-latest", - alias = "claude-sonnet-4-5-thinking", - alias = "claude-sonnet-4-5-thinking-latest" - )] - ClaudeSonnet4_5, - #[serde( - rename = "claude-opus-4-1", - alias = "claude-opus-4-1-latest", - alias = "claude-opus-4-1-thinking", - alias = "claude-opus-4-1-thinking-latest" + rename = "claude-opus-4-8", + alias = "claude-opus-4-8-latest", + alias = "claude-opus-4-8-thinking", + alias = "claude-opus-4-8-thinking-latest" )] - ClaudeOpus4_1, + ClaudeOpus4_8, #[serde( - rename = "claude-opus-4-5", - alias = "claude-opus-4-5-latest", - alias = "claude-opus-4-5-thinking", - alias = "claude-opus-4-5-thinking-latest" + rename = "claude-opus-4-7", + alias = "claude-opus-4-7-latest", + alias = "claude-opus-4-7-thinking", + alias = "claude-opus-4-7-thinking-latest" )] - ClaudeOpus4_5, + ClaudeOpus4_7, #[serde( rename = "claude-opus-4-6", alias = "claude-opus-4-6-latest", @@ -87,19 +77,26 @@ pub enum Model { )] ClaudeOpus4_6, #[serde( - rename = "claude-opus-4-7", - alias = "claude-opus-4-7-latest", - alias = "claude-opus-4-7-thinking", - alias = "claude-opus-4-7-thinking-latest" + rename = "claude-opus-4-5", + alias = "claude-opus-4-5-latest", + alias = "claude-opus-4-5-thinking", + alias = "claude-opus-4-5-thinking-latest" )] - ClaudeOpus4_7, + ClaudeOpus4_5, #[serde( - rename = "claude-opus-4-8", - alias = "claude-opus-4-8-latest", - alias = "claude-opus-4-8-thinking", - alias = "claude-opus-4-8-thinking-latest" + rename = "claude-opus-4-1", + alias = "claude-opus-4-1-latest", + alias = "claude-opus-4-1-thinking", + alias = "claude-opus-4-1-thinking-latest" )] - ClaudeOpus4_8, + ClaudeOpus4_1, + #[serde( + rename = "claude-sonnet-5", + alias = "claude-sonnet-5-latest", + alias = "claude-sonnet-5-thinking", + alias = "claude-sonnet-5-thinking-latest" + )] + ClaudeSonnet5, #[serde( rename = "claude-sonnet-4-6", alias = "claude-sonnet-4-6-latest", @@ -107,6 +104,23 @@ pub enum Model { alias = "claude-sonnet-4-6-thinking-latest" )] ClaudeSonnet4_6, + #[default] + #[serde( + rename = "claude-sonnet-4-5", + alias = "claude-sonnet-4-5-latest", + alias = "claude-sonnet-4-5-thinking", + alias = "claude-sonnet-4-5-thinking-latest" + )] + ClaudeSonnet4_5, + #[serde( + rename = "claude-sonnet-4", + alias = "claude-sonnet-4-latest", + alias = "claude-sonnet-4-thinking", + alias = "claude-sonnet-4-thinking-latest" + )] + ClaudeSonnet4, + #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] + ClaudeHaiku4_5, // Meta Llama 4 models #[serde(rename = "llama-4-scout-17b")] @@ -213,13 +227,15 @@ pub enum Model { }, } -impl Model { +impl ConverseModel { pub fn default_fast(_region: &str) -> Self { Self::ClaudeHaiku4_5 } pub fn from_id(id: &str) -> anyhow::Result { - if id.starts_with("claude-opus-4-8") { + if id.starts_with("claude-fable-5") { + Ok(Self::ClaudeFable5) + } else if id.starts_with("claude-opus-4-8") { Ok(Self::ClaudeOpus4_8) } else if id.starts_with("claude-opus-4-7") { Ok(Self::ClaudeOpus4_7) @@ -229,6 +245,8 @@ impl Model { Ok(Self::ClaudeOpus4_5) } else if id.starts_with("claude-opus-4-1") { Ok(Self::ClaudeOpus4_1) + } else if id.starts_with("claude-sonnet-5") { + Ok(Self::ClaudeSonnet5) } else if id.starts_with("claude-sonnet-4-6") { Ok(Self::ClaudeSonnet4_6) } else if id.starts_with("claude-sonnet-4-5") { @@ -244,15 +262,17 @@ impl Model { pub fn id(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "claude-haiku-4-5", - Self::ClaudeSonnet4 => "claude-sonnet-4", - Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", - Self::ClaudeOpus4_1 => "claude-opus-4-1", - Self::ClaudeOpus4_5 => "claude-opus-4-5", - Self::ClaudeOpus4_6 => "claude-opus-4-6", - Self::ClaudeOpus4_7 => "claude-opus-4-7", + Self::ClaudeFable5 => "claude-fable-5", Self::ClaudeOpus4_8 => "claude-opus-4-8", + Self::ClaudeOpus4_7 => "claude-opus-4-7", + Self::ClaudeOpus4_6 => "claude-opus-4-6", + Self::ClaudeOpus4_5 => "claude-opus-4-5", + Self::ClaudeOpus4_1 => "claude-opus-4-1", + Self::ClaudeSonnet5 => "claude-sonnet-5", Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", + Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", + Self::ClaudeSonnet4 => "claude-sonnet-4", + Self::ClaudeHaiku4_5 => "claude-haiku-4-5", Self::Llama4Scout17B => "llama-4-scout-17b", Self::Llama4Maverick17B => "llama-4-maverick-17b", Self::Gemma3_4B => "gemma-3-4b", @@ -295,15 +315,17 @@ impl Model { pub fn request_id(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "anthropic.claude-haiku-4-5-20251001-v1:0", - Self::ClaudeSonnet4 => "anthropic.claude-sonnet-4-20250514-v1:0", - Self::ClaudeSonnet4_5 => "anthropic.claude-sonnet-4-5-20250929-v1:0", - Self::ClaudeOpus4_1 => "anthropic.claude-opus-4-1-20250805-v1:0", - Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0", - Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", - Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", + Self::ClaudeFable5 => "anthropic.claude-fable-5", Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", + Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", + Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", + Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0", + Self::ClaudeOpus4_1 => "anthropic.claude-opus-4-1-20250805-v1:0", + Self::ClaudeSonnet5 => "anthropic.claude-sonnet-5", Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6", + Self::ClaudeSonnet4_5 => "anthropic.claude-sonnet-4-5-20250929-v1:0", + Self::ClaudeSonnet4 => "anthropic.claude-sonnet-4-20250514-v1:0", + Self::ClaudeHaiku4_5 => "anthropic.claude-haiku-4-5-20251001-v1:0", Self::Llama4Scout17B => "meta.llama4-scout-17b-instruct-v1:0", Self::Llama4Maverick17B => "meta.llama4-maverick-17b-instruct-v1:0", Self::Gemma3_4B => "google.gemma-3-4b-it", @@ -346,15 +368,17 @@ impl Model { pub fn display_name(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", - Self::ClaudeSonnet4 => "Claude Sonnet 4", - Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", - Self::ClaudeOpus4_1 => "Claude Opus 4.1", - Self::ClaudeOpus4_5 => "Claude Opus 4.5", - Self::ClaudeOpus4_6 => "Claude Opus 4.6", - Self::ClaudeOpus4_7 => "Claude Opus 4.7", + Self::ClaudeFable5 => "Claude Fable 5", Self::ClaudeOpus4_8 => "Claude Opus 4.8", + Self::ClaudeOpus4_7 => "Claude Opus 4.7", + Self::ClaudeOpus4_6 => "Claude Opus 4.6", + Self::ClaudeOpus4_5 => "Claude Opus 4.5", + Self::ClaudeOpus4_1 => "Claude Opus 4.1", + Self::ClaudeSonnet5 => "Claude Sonnet 5", Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", + Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", + Self::ClaudeSonnet4 => "Claude Sonnet 4", + Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", Self::Llama4Scout17B => "Llama 4 Scout 17B", Self::Llama4Maverick17B => "Llama 4 Maverick 17B", Self::Gemma3_4B => "Gemma 3 4B", @@ -399,14 +423,16 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => 1_000_000, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 1_000_000, Self::ClaudeOpus4_1 => 200_000, Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000, Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000, @@ -434,13 +460,17 @@ impl Model { pub fn max_output_tokens(&self) -> u64 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 + Self::ClaudeFable5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 => 128_000, + Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeSonnet4_6 => 64_000, + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 64_000, Self::ClaudeOpus4_1 => 32_000, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 => 128_000, Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::Gemma3_4B @@ -472,15 +502,17 @@ impl Model { pub fn default_temperature(&self) -> f32 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => 1.0, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 1.0, Self::Custom { default_temperature, .. @@ -491,15 +523,17 @@ impl Model { pub fn supports_tool_use(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true, Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true, Self::Devstral2_123B | Self::Ministral14B => true, @@ -523,15 +557,17 @@ impl Model { pub fn supports_images(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::NovaLite | Self::NovaPro => true, Self::PixtralLarge => true, Self::Qwen3VL235B => true, @@ -542,15 +578,17 @@ impl Model { pub fn supports_caching(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::Custom { cache_configuration, .. @@ -562,27 +600,37 @@ impl Model { pub fn supports_thinking(&self) -> bool { matches!( self, - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 ) } pub fn supports_adaptive_thinking(&self) -> bool { matches!( self, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + Self::ClaudeFable5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 ) } pub fn supports_xhigh_adaptive_thinking(&self) -> bool { - matches!(self, Self::ClaudeOpus4_8) + matches!( + self, + Self::ClaudeFable5 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet5 + ) } pub fn thinking_mode(&self) -> BedrockModelMode { @@ -608,14 +656,16 @@ impl Model { let supports_global = matches!( self, - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite ); @@ -669,14 +719,16 @@ impl Model { // Global inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite, "global", ) => Ok(format!("{}.{}", region_group, model_id)), @@ -686,15 +738,17 @@ impl Model { // US region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::NovaLite @@ -711,13 +765,13 @@ impl Model { // EU region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 - | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, @@ -726,61 +780,315 @@ impl Model { // Australia region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 - | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6, + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5, "au", ) => Ok(format!("{}.{}", region_group, model_id)), // Japan region inference profiles ( - Self::ClaudeHaiku4_5 + Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4_6 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite, "jp", ) => Ok(format!("{}.{}", region_group, model_id)), // APAC region inference profiles (other than AU/JP) ( - Self::ClaudeHaiku4_5 + Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, "apac", ) => Ok(format!("{}.{}", region_group, model_id)), + (Self::ClaudeFable5 | Self::ClaudeSonnet5, _) => Ok(format!("global.{}", model_id)), + // Default: use model ID directly _ => Ok(model_id.into()), } } } +/// The wire protocol used to talk to a [`MantleModel`] on the `bedrock-mantle` endpoint. +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] +pub enum MantleProtocol { + /// The OpenAI Chat Completions API (`/chat/completions`). + #[default] + ChatCompletions, + /// The OpenAI Responses API (`/responses`). + Responses, +} + +/// Models only reachable through the `bedrock-mantle` endpoint's +/// OpenAI-compatible APIs, i.e. with no `Converse`/`Invoke` support on +/// `bedrock-runtime`. +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, EnumIter)] +pub enum MantleModel { + #[serde(rename = "gpt-5.6-sol")] + Gpt5_6Sol, + #[serde(rename = "gpt-5.6-terra")] + Gpt5_6Terra, + #[serde(rename = "gpt-5.6-luna")] + Gpt5_6Luna, + #[serde(rename = "gpt-5.5")] + Gpt5_5, + #[serde(rename = "gpt-5.4")] + Gpt5_4, + #[serde(rename = "grok-4.3")] + Grok4_3, + #[serde(rename = "custom")] + Custom { + name: String, + display_name: Option, + max_tokens: u64, + max_output_tokens: Option, + protocol: MantleProtocol, + supports_tools: bool, + supports_images: bool, + supports_thinking: bool, + }, +} + +impl MantleModel { + /// The model id Zed uses internally (also used as the `name` in settings). + pub fn id(&self) -> &str { + match self { + Self::Gpt5_6Sol => "gpt-5.6-sol", + Self::Gpt5_6Terra => "gpt-5.6-terra", + Self::Gpt5_6Luna => "gpt-5.6-luna", + Self::Gpt5_5 => "gpt-5.5", + Self::Gpt5_4 => "gpt-5.4", + Self::Grok4_3 => "grok-4.3", + Self::Custom { name, .. } => name, + } + } + + /// The model id as expected in Bedrock Mantle request bodies, e.g. `openai.gpt-5.5`. + pub fn request_id(&self) -> &str { + match self { + Self::Gpt5_6Sol => "openai.gpt-5.6-sol", + Self::Gpt5_6Terra => "openai.gpt-5.6-terra", + Self::Gpt5_6Luna => "openai.gpt-5.6-luna", + Self::Gpt5_5 => "openai.gpt-5.5", + Self::Gpt5_4 => "openai.gpt-5.4", + Self::Grok4_3 => "xai.grok-4.3", + Self::Custom { name, .. } => name, + } + } + + pub fn display_name(&self) -> &str { + match self { + Self::Gpt5_6Sol => "GPT-5.6 Sol", + Self::Gpt5_6Terra => "GPT-5.6 Terra", + Self::Gpt5_6Luna => "GPT-5.6 Luna", + Self::Gpt5_5 => "GPT-5.5", + Self::Gpt5_4 => "GPT-5.4", + Self::Grok4_3 => "Grok 4.3", + Self::Custom { + display_name, name, .. + } => display_name.as_deref().unwrap_or(name.as_str()), + } + } + + /// Which OpenAI-compatible API this model must be called through. + pub fn protocol(&self) -> MantleProtocol { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => MantleProtocol::Responses, + Self::Custom { protocol, .. } => *protocol, + } + } + + pub fn max_token_count(&self) -> u64 { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 => 272_000, + Self::Grok4_3 => 1_000_000, + Self::Custom { max_tokens, .. } => *max_tokens, + } + } + + pub fn max_output_tokens(&self) -> u64 { + match self { + // AWS doesn't document a hard cap for the GPT-5.x models on Mantle. + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 => 128_000, + Self::Grok4_3 => 131_072, + Self::Custom { + max_output_tokens, .. + } => max_output_tokens.unwrap_or(4_096), + } + } + + pub fn supports_tools(&self) -> bool { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => true, + Self::Custom { supports_tools, .. } => *supports_tools, + } + } + + pub fn supports_images(&self) -> bool { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => true, + Self::Custom { + supports_images, .. + } => *supports_images, + } + } + + pub fn supports_thinking(&self) -> bool { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => true, + Self::Custom { + supports_thinking, .. + } => *supports_thinking, + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_builtin_mantle_models_use_responses_protocol() { + assert_eq!(MantleModel::Gpt5_6Sol.protocol(), MantleProtocol::Responses); + assert_eq!( + MantleModel::Gpt5_6Terra.protocol(), + MantleProtocol::Responses + ); + assert_eq!( + MantleModel::Gpt5_6Luna.protocol(), + MantleProtocol::Responses + ); + assert_eq!(MantleModel::Gpt5_5.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Gpt5_4.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Grok4_3.protocol(), MantleProtocol::Responses); + } + + #[test] + fn test_gpt_5_6_mantle_model_metadata() { + // Values mirror the AWS Bedrock model cards for GPT-5.6 Sol/Terra/Luna, + // which are served only through the `bedrock-mantle` Responses API. + for (model, id, request_id, display_name) in [ + ( + MantleModel::Gpt5_6Sol, + "gpt-5.6-sol", + "openai.gpt-5.6-sol", + "GPT-5.6 Sol", + ), + ( + MantleModel::Gpt5_6Terra, + "gpt-5.6-terra", + "openai.gpt-5.6-terra", + "GPT-5.6 Terra", + ), + ( + MantleModel::Gpt5_6Luna, + "gpt-5.6-luna", + "openai.gpt-5.6-luna", + "GPT-5.6 Luna", + ), + ] { + assert_eq!(model.id(), id); + assert_eq!(model.request_id(), request_id); + assert_eq!(model.display_name(), display_name); + assert_eq!(model.max_token_count(), 272_000); + assert!(model.supports_tools()); + assert!(model.supports_images()); + assert!(model.supports_thinking()); + } + } + + #[test] + fn test_builtin_mantle_models_have_unique_ids_and_sane_token_limits() { + use std::collections::HashSet; + use strum::IntoEnumIterator; + + let mut ids = HashSet::new(); + let mut request_ids = HashSet::new(); + for model in + MantleModel::iter().filter(|model| !matches!(model, MantleModel::Custom { .. })) + { + assert!( + ids.insert(model.id().to_string()), + "duplicate MantleModel id: {}", + model.id() + ); + assert!( + request_ids.insert(model.request_id().to_string()), + "duplicate MantleModel request_id: {}", + model.request_id() + ); + assert!( + model.max_output_tokens() <= model.max_token_count(), + "{} has max_output_tokens ({}) greater than max_token_count ({})", + model.id(), + model.max_output_tokens(), + model.max_token_count() + ); + } + } + #[test] fn test_us_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?, + ConverseModel::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::NovaPro.cross_region_inference_id("us-east-2", false)?, + ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-sonnet-5" + ); + assert_eq!( + ConverseModel::NovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" ); assert_eq!( - Model::DeepSeekR1.cross_region_inference_id("us-east-1", false)?, + ConverseModel::DeepSeekR1.cross_region_inference_id("us-east-1", false)?, "us.deepseek.r1-v1:0" ); Ok(()) @@ -789,40 +1097,61 @@ mod tests { #[test] fn test_eu_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::NovaLite.cross_region_inference_id("eu-north-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("eu-north-1", false)?, "eu.amazon.nova-lite-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-8" ); Ok(()) } + #[test] + fn test_inference_profile_only_models_fall_back_to_global() -> anyhow::Result<()> { + assert_eq!( + ConverseModel::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?, + "global.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?, + "global.anthropic.claude-sonnet-5" + ); + assert_eq!( + ConverseModel::ClaudeFable5.cross_region_inference_id("ap-southeast-2", false)?, + "global.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?, + "global.anthropic.claude-sonnet-5" + ); + Ok(()) + } + #[test] fn test_apac_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?, "apac.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::NovaLite.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("ap-south-1", false)?, "apac.amazon.nova-lite-v1:0" ); Ok(()) @@ -831,23 +1160,23 @@ mod tests { #[test] fn test_au_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?, "au.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-8" ); Ok(()) @@ -856,15 +1185,15 @@ mod tests { #[test] fn test_jp_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?, "jp.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?, + ConverseModel::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?, "jp.amazon.nova-2-lite-v1:0" ); Ok(()) @@ -873,7 +1202,7 @@ mod tests { #[test] fn test_ca_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::NovaLite.cross_region_inference_id("ca-central-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("ca-central-1", false)?, "ca.amazon.nova-lite-v1:0" ); Ok(()) @@ -882,11 +1211,11 @@ mod tests { #[test] fn test_gov_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" ); Ok(()) @@ -895,37 +1224,45 @@ mod tests { #[test] fn test_global_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?, "global.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, "global.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-8" ); assert_eq!( - Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-sonnet-5" + ); + assert_eq!( + ConverseModel::Nova2Lite.cross_region_inference_id("us-east-1", true)?, "global.amazon.nova-2-lite-v1:0" ); // Models without global support fall back to regional assert_eq!( - Model::NovaPro.cross_region_inference_id("us-east-1", true)?, + ConverseModel::NovaPro.cross_region_inference_id("us-east-1", true)?, "us.amazon.nova-pro-v1:0" ); Ok(()) @@ -935,27 +1272,27 @@ mod tests { fn test_models_without_cross_region() -> anyhow::Result<()> { // Models without cross-region support return their request_id directly assert_eq!( - Model::Gemma3_4B.cross_region_inference_id("us-east-1", false)?, + ConverseModel::Gemma3_4B.cross_region_inference_id("us-east-1", false)?, "google.gemma-3-4b-it" ); assert_eq!( - Model::MistralLarge3.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::MistralLarge3.cross_region_inference_id("eu-west-1", false)?, "mistral.mistral-large-3-675b-instruct" ); assert_eq!( - Model::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?, "qwen.qwen3-vl-235b-a22b" ); assert_eq!( - Model::GptOss120B.cross_region_inference_id("us-east-1", false)?, + ConverseModel::GptOss120B.cross_region_inference_id("us-east-1", false)?, "openai.gpt-oss-120b-1:0" ); assert_eq!( - Model::MiniMaxM2.cross_region_inference_id("us-east-1", false)?, + ConverseModel::MiniMaxM2.cross_region_inference_id("us-east-1", false)?, "minimax.minimax-m2" ); assert_eq!( - Model::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?, + ConverseModel::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?, "moonshot.kimi-k2-thinking" ); Ok(()) @@ -963,7 +1300,7 @@ mod tests { #[test] fn test_custom_model_inference_ids() -> anyhow::Result<()> { - let custom_model = Model::Custom { + let custom_model = ConverseModel::Custom { name: "custom.my-model-v1:0".to_string(), max_tokens: 100000, display_name: Some("My Custom Model".to_string()), @@ -985,58 +1322,90 @@ mod tests { #[test] fn test_friendly_id_vs_request_id() { - assert_eq!(Model::ClaudeSonnet4_5.id(), "claude-sonnet-4-5"); - assert_eq!(Model::NovaLite.id(), "nova-lite"); - assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1"); - assert_eq!(Model::Llama4Scout17B.id(), "llama-4-scout-17b"); + assert_eq!(ConverseModel::ClaudeSonnet4_5.id(), "claude-sonnet-4-5"); + assert_eq!(ConverseModel::NovaLite.id(), "nova-lite"); + assert_eq!(ConverseModel::DeepSeekR1.id(), "deepseek-r1"); + assert_eq!(ConverseModel::Llama4Scout17B.id(), "llama-4-scout-17b"); + assert_eq!(ConverseModel::ClaudeFable5.id(), "claude-fable-5"); + assert_eq!(ConverseModel::ClaudeSonnet5.id(), "claude-sonnet-5"); assert_eq!( - Model::ClaudeSonnet4_5.request_id(), + ConverseModel::ClaudeSonnet4_5.request_id(), "anthropic.claude-sonnet-4-5-20250929-v1:0" ); - assert_eq!(Model::NovaLite.request_id(), "amazon.nova-lite-v1:0"); - assert_eq!(Model::DeepSeekR1.request_id(), "deepseek.r1-v1:0"); assert_eq!( - Model::Llama4Scout17B.request_id(), + ConverseModel::NovaLite.request_id(), + "amazon.nova-lite-v1:0" + ); + assert_eq!(ConverseModel::DeepSeekR1.request_id(), "deepseek.r1-v1:0"); + assert_eq!( + ConverseModel::Llama4Scout17B.request_id(), "meta.llama4-scout-17b-instruct-v1:0" ); + assert_eq!( + ConverseModel::ClaudeFable5.request_id(), + "anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.request_id(), + "anthropic.claude-sonnet-5" + ); // Thinking aliases deserialize to the same model - assert_eq!(Model::ClaudeSonnet4.id(), "claude-sonnet-4"); + assert_eq!(ConverseModel::ClaudeSonnet4.id(), "claude-sonnet-4"); assert_eq!( - Model::from_id("claude-sonnet-4-thinking").unwrap().id(), + ConverseModel::from_id("claude-sonnet-4-thinking") + .unwrap() + .id(), "claude-sonnet-4" ); + assert_eq!( + ConverseModel::from_id("claude-fable-5-thinking") + .unwrap() + .id(), + "claude-fable-5" + ); + assert_eq!( + ConverseModel::from_id("claude-sonnet-5-thinking") + .unwrap() + .id(), + "claude-sonnet-5" + ); } #[test] fn test_thinking_modes() { - assert!(Model::ClaudeHaiku4_5.supports_thinking()); - assert!(Model::ClaudeSonnet4.supports_thinking()); - assert!(Model::ClaudeSonnet4_5.supports_thinking()); - assert!(Model::ClaudeOpus4_6.supports_thinking()); - - assert!(!Model::ClaudeSonnet4.supports_adaptive_thinking()); - assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking()); - assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking()); - assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); - assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeHaiku4_5.supports_thinking()); + assert!(ConverseModel::ClaudeSonnet4.supports_thinking()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_thinking()); + assert!(ConverseModel::ClaudeOpus4_6.supports_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_thinking()); + + assert!(!ConverseModel::ClaudeSonnet4.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus4_6.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet4_6.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet5.supports_adaptive_thinking()); + assert!(!ConverseModel::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); assert_eq!( - Model::ClaudeSonnet4.thinking_mode(), + ConverseModel::ClaudeSonnet4.thinking_mode(), BedrockModelMode::Thinking { budget_tokens: Some(4096) } ); assert_eq!( - Model::ClaudeOpus4_6.thinking_mode(), + ConverseModel::ClaudeOpus4_6.thinking_mode(), BedrockModelMode::AdaptiveThinking { effort: BedrockAdaptiveThinkingEffort::High } ); assert_eq!( - Model::ClaudeHaiku4_5.thinking_mode(), + ConverseModel::ClaudeHaiku4_5.thinking_mode(), BedrockModelMode::Thinking { budget_tokens: Some(4096) } @@ -1045,38 +1414,44 @@ mod tests { #[test] fn test_max_token_count() { - assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000); - assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000); - assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000); - assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeSonnet4_5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeOpus4_6.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeFable5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeSonnet5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::Llama4Scout17B.max_token_count(), 128_000); + assert_eq!(ConverseModel::NovaPremier.max_token_count(), 1_000_000); } #[test] fn test_max_output_tokens() { - assert_eq!(Model::ClaudeSonnet4_5.max_output_tokens(), 64_000); - assert_eq!(Model::ClaudeOpus4_6.max_output_tokens(), 128_000); - assert_eq!(Model::ClaudeOpus4_1.max_output_tokens(), 32_000); - assert_eq!(Model::Gemma3_4B.max_output_tokens(), 8_192); + assert_eq!(ConverseModel::ClaudeSonnet4_5.max_output_tokens(), 64_000); + assert_eq!(ConverseModel::ClaudeOpus4_6.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeFable5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeSonnet5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeOpus4_1.max_output_tokens(), 32_000); + assert_eq!(ConverseModel::Gemma3_4B.max_output_tokens(), 8_192); } #[test] fn test_supports_tool_use() { - assert!(Model::ClaudeSonnet4_5.supports_tool_use()); - assert!(Model::NovaPro.supports_tool_use()); - assert!(Model::MistralLarge3.supports_tool_use()); - assert!(!Model::Gemma3_4B.supports_tool_use()); - assert!(Model::Qwen3_32B.supports_tool_use()); - assert!(Model::MiniMaxM2.supports_tool_use()); - assert!(Model::KimiK2_5.supports_tool_use()); - assert!(Model::DeepSeekR1.supports_tool_use()); - assert!(!Model::Llama4Scout17B.supports_tool_use()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_tool_use()); + assert!(ConverseModel::ClaudeFable5.supports_tool_use()); + assert!(ConverseModel::NovaPro.supports_tool_use()); + assert!(ConverseModel::MistralLarge3.supports_tool_use()); + assert!(!ConverseModel::Gemma3_4B.supports_tool_use()); + assert!(ConverseModel::Qwen3_32B.supports_tool_use()); + assert!(ConverseModel::MiniMaxM2.supports_tool_use()); + assert!(ConverseModel::KimiK2_5.supports_tool_use()); + assert!(ConverseModel::DeepSeekR1.supports_tool_use()); + assert!(!ConverseModel::Llama4Scout17B.supports_tool_use()); } #[test] fn test_supports_caching() { - assert!(Model::ClaudeSonnet4_5.supports_caching()); - assert!(Model::ClaudeOpus4_6.supports_caching()); - assert!(!Model::Llama4Scout17B.supports_caching()); - assert!(!Model::NovaPro.supports_caching()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_caching()); + assert!(ConverseModel::ClaudeOpus4_6.supports_caching()); + assert!(ConverseModel::ClaudeFable5.supports_caching()); + assert!(!ConverseModel::Llama4Scout17B.supports_caching()); + assert!(!ConverseModel::NovaPro.supports_caching()); } } diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index c300ace11ae1d9..b73c64ff61eacc 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1,5 +1,5 @@ use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; -use imara_diff::{Algorithm, Sink, intern::InternedInput, sources::lines_with_terminator}; +use imara_diff::{Algorithm, Diff, InternedInput, sources::lines}; use language::{ Capability, DiffOptions, Language, LanguageName, LanguageRegistry, language_settings::LanguageSettings, word_diff_ranges, @@ -122,11 +122,37 @@ struct InternalDiffHunk { } #[derive(Debug, Clone, PartialEq, Eq)] -struct PendingHunk { +pub struct PendingHunk { buffer_range: Range, diff_base_byte_range: Range, buffer_version: clock::Global, - new_status: DiffHunkSecondaryStatus, + sense: PendingSense, +} + +impl PendingHunk { + pub fn new( + buffer_range: Range, + diff_base_byte_range: Range, + buffer_version: clock::Global, + sense: PendingSense, + ) -> Self { + Self { + buffer_range, + diff_base_byte_range, + buffer_version, + sense, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingSense { + /// Override the secondary status of the matched hunk (used by the + /// uncommitted diff to show a hunk as staging/unstaging in place). + SetSecondaryStatus { stage: bool }, + /// Suppress the matched hunk entirely (used by the unstaged/staged diffs so + /// that a hunk disappears the moment it is staged/unstaged). + Suppress, } #[derive(Debug, Clone)] @@ -294,6 +320,56 @@ impl BufferDiffSnapshot { self.hunks_intersecting_range_impl(filter, buffer, unstaged_counterpart) } + /// Like [`hunks_intersecting_range`], but ignores optimistic pending hunks + /// (both secondary-status overrides and suppressions) and does not compute a + /// secondary status. + pub fn raw_hunks_intersecting_range<'a>( + &'a self, + range: Range, + buffer: &'a text::BufferSnapshot, + ) -> impl 'a + Iterator { + let range = range.to_offset(buffer); + let filter = move |summary: &DiffHunkSummary| { + let summary_range = summary.buffer_range.to_offset(buffer); + !(summary_range.end < range.start) && !(summary_range.start > range.end) + }; + self.hunks + .filter::<_, DiffHunkSummary>(buffer, filter) + .map(move |hunk| { + let buffer_range = hunk.buffer_range.clone(); + DiffHunk { + range: buffer_range.to_point(buffer), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + buffer_range, + secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, + base_word_diffs: hunk.base_word_diffs.clone(), + buffer_word_diffs: hunk.buffer_word_diffs.clone(), + } + }) + } + + /// Maps a range in this diff's main buffer to the range it covers in the + /// base text, expanding to whole hunks wherever the range endpoints fall + /// inside or touch a hunk (`edit_for_old_position` is inclusive on both + /// boundaries, matching `raw_hunks_intersecting_range`). Used by the + /// index-write path to compute the index-coordinate footprint of a staging + /// operation; like the raw hunks, the mapping ignores optimistic pending + /// hunks. + pub fn base_text_range_for_buffer_range( + &self, + range: Range, + buffer: &text::BufferSnapshot, + ) -> Range { + let point_range = range.to_point(buffer); + let patch = self.patch_for_buffer_range(point_range.start..=point_range.end, buffer); + let start_point = patch.edit_for_old_position(point_range.start).new.start; + let end_point = patch.edit_for_old_position(point_range.end).new.end; + let base_text = self.base_text(); + let start = base_text.point_to_offset(start_point.min(base_text.max_point())); + let end = base_text.point_to_offset(end_point.min(base_text.max_point())); + start.min(end)..end + } + pub fn hunks_intersecting_range_rev<'a>( &'a self, range: Range, @@ -705,114 +781,80 @@ impl BufferDiffSnapshot { } impl BufferDiffSnapshot { - fn stage_or_unstage_hunks_impl( - &mut self, + // Compute the edits to apply to the index, and the resulting pending hunks, + // for a stage or unstage operation on the uncommitted diff. + pub fn compute_uncommitted_index_edits( + &self, unstaged_diff: &Self, stage: bool, hunks: &[DiffHunk], buffer: &text::BufferSnapshot, file_exists: bool, - ) -> Option { + ) -> (Option, Arc)>>, Vec) { let head_text = self .base_text_exists .then(|| self.base_text.as_rope().clone()); let index_text = unstaged_diff .base_text_exists .then(|| unstaged_diff.base_text.as_rope().clone()); + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); // If the file doesn't exist in either HEAD or the index, then the // entire file must be either created or deleted in the index. let (index_text, head_text) = match (index_text, head_text) { (Some(index_text), Some(head_text)) if file_exists || !stage => (index_text, head_text), (index_text, head_text) => { - let (new_index_text, new_status) = if stage { + let index_len = index_text.as_ref().map_or(0, |rope| rope.len()); + let new_index_text: Option = if stage { log::debug!("stage all"); - ( - file_exists.then(|| buffer.as_rope().clone()), - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending, - ) + file_exists.then(|| buffer.as_rope().clone()) } else { log::debug!("unstage all"); - ( - head_text, - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending, - ) + head_text }; - let hunk = PendingHunk { - buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), - diff_base_byte_range: 0..index_text.map_or(0, |rope| rope.len()), - buffer_version: buffer.version().clone(), - new_status, - }; - self.pending_hunks = SumTree::from_item(hunk, buffer); - return new_index_text; + let pending = vec![PendingHunk::new( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + 0..index_len, + version, + sense, + )]; + let edits = + new_index_text.map(|rope| vec![(0..index_len, Arc::from(rope.to_string()))]); + return (edits, pending); } }; - let mut pending_hunks = SumTree::new(buffer); - let mut old_pending_hunks = self.pending_hunks.cursor::(buffer); - - // first, merge new hunks into pending_hunks - for DiffHunk { - buffer_range, - diff_base_byte_range, - secondary_status, - .. - } in hunks.iter().cloned() - { - let preceding_pending_hunks = old_pending_hunks.slice(&buffer_range.start, Bias::Left); - pending_hunks.append(preceding_pending_hunks, buffer); - - // Skip all overlapping or adjacent old pending hunks - while old_pending_hunks.item().is_some_and(|old_hunk| { - old_hunk - .buffer_range - .start - .cmp(&buffer_range.end, buffer) - .is_le() - }) { - old_pending_hunks.next(); - } - - if (stage && secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) - || (!stage && secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk) - { - continue; - } - - pending_hunks.push( - PendingHunk { - buffer_range, - diff_base_byte_range, - buffer_version: buffer.version().clone(), - new_status: if stage { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending - } else { - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending - }, - }, - buffer, - ); - } - // append the remainder - pending_hunks.append(old_pending_hunks.suffix(), buffer); - let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::(buffer); unstaged_hunk_cursor.next(); - // then, iterate over all pending hunks (both new ones and the existing ones) and compute the edits let mut prev_unstaged_hunk_buffer_end = 0; let mut prev_unstaged_hunk_base_text_end = 0; - let mut edits = Vec::<(Range, String)>::new(); - let mut pending_hunks_iter = pending_hunks.iter().cloned().peekable(); - while let Some(PendingHunk { - buffer_range, - diff_base_byte_range, - new_status, - .. - }) = pending_hunks_iter.next() - { + let mut edits = Vec::<(Range, Arc)>::new(); + let mut pending = Vec::::new(); + + // Process only the hunks the user acted on, skipping any already in the + // desired state. + let mut hunks_iter = hunks + .iter() + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .peekable(); + + while let Some(hunk) = hunks_iter.next() { + let buffer_range = hunk.buffer_range.clone(); + let diff_base_byte_range = hunk.diff_base_byte_range.clone(); + pending.push(PendingHunk::new( + buffer_range.clone(), + diff_base_byte_range.clone(), + version.clone(), + sense, + )); + // Advance unstaged_hunk_cursor to skip unstaged hunks before current hunk let skipped_unstaged = unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left); @@ -846,16 +888,20 @@ impl BufferDiffSnapshot { } } - // If any unstaged hunks were merged, then subsequent pending hunks may - // now overlap this hunk. Merge them. - if let Some(next_pending_hunk) = pending_hunks_iter.peek() { - let next_pending_hunk_offset_range = - next_pending_hunk.buffer_range.to_offset(buffer); - if next_pending_hunk_offset_range.start <= buffer_offset_range.end { - buffer_offset_range.end = buffer_offset_range - .end - .max(next_pending_hunk_offset_range.end); - pending_hunks_iter.next(); + // If any unstaged hunks were merged, then subsequent acted-on hunks + // may now overlap this hunk. Merge them. + if let Some(next_hunk) = hunks_iter.peek() { + let next_hunk_offset_range = next_hunk.buffer_range.to_offset(buffer); + if next_hunk_offset_range.start <= buffer_offset_range.end { + buffer_offset_range.end = + buffer_offset_range.end.max(next_hunk_offset_range.end); + let merged_hunk = hunks_iter.next().expect("peeked hunk exists"); + pending.push(PendingHunk::new( + merged_hunk.buffer_range.clone(), + merged_hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + )); continue; } } @@ -879,49 +925,59 @@ impl BufferDiffSnapshot { let index_start = index_start.min(index_end); let index_byte_range = index_start..index_end; - let replacement_text = match new_status { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => { - log::debug!("staging hunk {:?}", buffer_offset_range); + let replacement_text: Arc = if stage { + log::debug!("staging hunk {:?}", buffer_offset_range); + Arc::from( buffer .text_for_range(buffer_offset_range) - .collect::() - } - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => { - log::debug!("unstaging hunk {:?}", buffer_offset_range); + .collect::(), + ) + } else { + log::debug!("unstaging hunk {:?}", buffer_offset_range); + Arc::from( head_text .chunks_in_range(diff_base_byte_range.clone()) - .collect::() - } - _ => { - debug_assert!(false); - continue; - } + .collect::(), + ) }; - edits.push((index_byte_range, replacement_text)); + // Distinct worktree hunks can project to touching index ranges + // (e.g. a staged deletion ending exactly where the next hunk's + // index position starts). Merge them so the edit list stays + // strictly disjoint, which the pending-edit eviction logic relies + // on to not evict one of these edits when the other is inserted. + if let Some((last_range, last_text)) = edits.last_mut() + && index_byte_range.start <= last_range.end + { + debug_assert!(index_byte_range.start == last_range.end); + debug_assert!(index_byte_range.end >= last_range.end); + last_range.end = index_byte_range.end; + let mut merged_text = + String::with_capacity(last_text.len() + replacement_text.len()); + merged_text.push_str(last_text); + merged_text.push_str(&replacement_text); + *last_text = Arc::from(merged_text); + } else { + edits.push((index_byte_range, replacement_text)); + } } - drop(pending_hunks_iter); - drop(old_pending_hunks); - self.pending_hunks = pending_hunks; #[cfg(debug_assertions)] // invariants: non-overlapping and sorted { for window in edits.windows(2) { let (range_a, range_b) = (&window[0].0, &window[1].0); - debug_assert!(range_a.end < range_b.start); + debug_assert!( + range_a.end < range_b.start, + "index edits out of order or overlapping: {:?}", + edits + .iter() + .map(|(range, text)| (range.clone(), text.len())) + .collect::>() + ); } } - let mut new_index_text = Rope::new(); - let mut index_cursor = index_text.cursor(0); - - for (old_range, replacement_text) in edits { - new_index_text.append(index_cursor.slice(old_range.start)); - index_cursor.seek_forward(old_range.end); - new_index_text.push(&replacement_text); - } - new_index_text.append(index_cursor.suffix()); - Some(new_index_text) + (Some(edits), pending) } } @@ -1005,8 +1061,17 @@ impl BufferDiffSnapshot { start_anchor..end_anchor, ) { - has_pending = true; - secondary_status = pending_hunk.new_status; + match pending_hunk.sense { + PendingSense::SetSecondaryStatus { stage } => { + has_pending = true; + secondary_status = if stage { + DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + } else { + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending + }; + } + PendingSense::Suppress => continue, + } } } @@ -1127,13 +1192,20 @@ fn compute_hunks( return tree; } - let input = InternedInput::new( - lines_with_terminator(diff_base.as_ref()), - lines_with_terminator(buffer_text.as_str()), - ); - let sink = HunkSink::new(&diff_base, &diff_base_rope, buffer, diff_options.as_ref()); - let hunks = imara_diff::diff(Algorithm::Histogram, &input, sink); - for hunk in hunks { + let input = InternedInput::new(lines(diff_base.as_ref()), lines(buffer_text.as_str())); + let mut diff = Diff::compute(Algorithm::Histogram, &input); + // Canonicalize the placement of ambiguous hunks (git's slider/indent + // heuristic). Without this, diffs of the same buffer against different + // base texts (e.g. HEAD vs index) can anchor the same logical change at + // different rows, and code that correlates hunks across those diffs + // misbehaves: hunks render as staged when they aren't, and staging or + // unstaging them corrupts the index. + diff.postprocess_lines(&input); + let mut sink = HunkSink::new(&diff_base, &diff_base_rope, buffer, diff_options.as_ref()); + for hunk in diff.hunks() { + sink.process_change(hunk.before, hunk.after); + } + for hunk in sink.finish() { tree.push(hunk, buffer); } } else { @@ -1179,7 +1251,7 @@ impl<'a> HunkSink<'a> { fn compute_line_offsets(text: &str) -> Vec { let mut offsets = vec![0]; let mut offset = 0; - for line in lines_with_terminator(text) { + for line in lines(text) { offset += line.len(); offsets.push(offset); } @@ -1187,9 +1259,7 @@ impl<'a> HunkSink<'a> { } } -impl Sink for HunkSink<'_> { - type Out = Vec; - +impl HunkSink<'_> { fn process_change(&mut self, before: Range, after: Range) { let old_start = before.start as usize; let old_end = before.end as usize; @@ -1256,7 +1326,7 @@ impl Sink for HunkSink<'_> { }); } - fn finish(self) -> Self::Out { + fn finish(self) -> Vec { self.hunks } } @@ -1474,7 +1544,6 @@ pub struct DiffChanged { pub enum BufferDiffEvent { BaseTextChanged, DiffChanged(DiffChanged), - HunksStagedOrUnstaged(Option), } impl EventEmitter for BufferDiff {} @@ -1594,24 +1663,195 @@ impl BufferDiff { let Some(diff_snapshot) = &mut self.diff_snapshot else { return; }; - if self.secondary_diff.is_some() { - diff_snapshot.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()); + let Some((first, last)) = diff_snapshot + .pending_hunks + .first() + .zip(diff_snapshot.pending_hunks.last()) + else { + return; + }; + let changed_range = first.buffer_range.start..last.buffer_range.end; + let base_text_changed_range = + first.diff_base_byte_range.start..last.diff_base_byte_range.end; + let buffer = diff_snapshot.buffer_snapshot.clone(); + diff_snapshot.pending_hunks = SumTree::new(&buffer); + cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range.clone()), + base_text_changed_range: Some(base_text_changed_range), + extended_range: Some(changed_range), + base_text_changed: false, + })); + } + + /// Installs optimistic pending hunks in this diff, merging them with any + /// existing pending hunks (newest wins on overlap) and emitting a + /// `DiffChanged` covering both the new hunks and any existing pending hunks + /// they replace. `hunks` must be sorted by `buffer_range.start` and + /// non-overlapping. + /// + /// `buffer` must be a current snapshot of this diff's main buffer: the + /// incoming hunks carry anchors minted from the current buffer, which this + /// diff's internal snapshot (from the last settled recalculation) may not + /// have observed yet. + pub fn set_pending_hunks( + &mut self, + hunks: &[PendingHunk], + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + if hunks.is_empty() { + return; + } + let Some(diff_snapshot) = self.diff_snapshot.as_mut() else { + return; + }; + + let mut new_pending = SumTree::new(buffer); + let mut old = diff_snapshot + .pending_hunks + .cursor::(buffer); + let mut changed_start: Option = None; + let mut changed_end: Option = None; + let mut base_start = usize::MAX; + let mut base_end = 0usize; + let mut extend_changed_range = |buffer_range: &Range, base_range: &Range| { + changed_start = Some(changed_start.map_or(buffer_range.start, |start| { + *start.min(&buffer_range.start, buffer) + })); + changed_end = Some( + changed_end.map_or(buffer_range.end, |end| *end.max(&buffer_range.end, buffer)), + ); + base_start = base_start.min(base_range.start); + base_end = base_end.max(base_range.end); + }; + for hunk in hunks { + let preceding = old.slice(&hunk.buffer_range.start, Bias::Left); + new_pending.append(preceding, buffer); + + // Drop any overlapping or adjacent existing pending hunks, folding + // them into the changed range so that views repaint their full + // extent (a replaced hunk can be wider than its replacement). + while let Some(old_hunk) = old.item() { + if old_hunk + .buffer_range + .start + .cmp(&hunk.buffer_range.end, buffer) + .is_gt() + { + break; + } + extend_changed_range(&old_hunk.buffer_range, &old_hunk.diff_base_byte_range); + old.next(); + } + + extend_changed_range(&hunk.buffer_range, &hunk.diff_base_byte_range); + new_pending.push(hunk.clone(), buffer); + } + new_pending.append(old.suffix(), buffer); + drop(old); + diff_snapshot.pending_hunks = new_pending; + + if let (Some(start), Some(end)) = (changed_start, changed_end) { + let changed_range = Some(start..end); cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { changed_range: changed_range.clone(), - base_text_changed_range: base_text_range, + base_text_changed_range: Some(base_start..base_end), extended_range: changed_range, base_text_changed: false, })); } } + /// Optimistically marks every stageable (resp. unstageable) hunk in this diff + /// as staging (resp. unstaging). Used by whole-file staging from the git + /// panel, where the actual index change is performed by `git add`/`reset` + /// rather than the optimistic index patch. + pub fn mark_all_hunks_pending( + &mut self, + stage: bool, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + sense, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + pub fn suppress_all_hunks_pending( + &mut self, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .raw_hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + buffer, + ) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + PendingSense::Suppress, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + /// Computes the index-text edits for unstaging the given staged (HEAD-vs-index) + /// hunks. `index_buffer` is this diff's main buffer (the index text). The + /// returned edits are in index coordinates. + pub fn unstage_staged_hunks( + &self, + hunks: &[DiffHunk], + index_buffer: &text::BufferSnapshot, + ) -> Option, Arc)>> { + let Some(diff_snapshot) = self.diff_snapshot.as_ref() else { + return Some(Vec::new()); + }; + // With no HEAD, the whole file is one staged addition; unstaging it + // removes the file from the index entirely. + if !diff_snapshot.base_text_exists { + return None; + } + let head_text = diff_snapshot.base_text.as_rope(); + let mut edits = hunks + .iter() + .map(|hunk| { + let index_range = hunk.buffer_range.to_offset(index_buffer); + let replacement_text: Arc = Arc::from( + head_text + .chunks_in_range(hunk.diff_base_byte_range.clone()) + .collect::(), + ); + (index_range, replacement_text) + }) + .collect::>(); + edits.sort_by_key(|(range, _)| range.start); + Some(edits) + } + + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_hunks( &mut self, stage: bool, @@ -1621,74 +1861,40 @@ impl BufferDiff { cx: &mut Context, ) -> Option { let secondary_diff = self.secondary_diff.clone()?; - let diff_snapshot = self.diff_snapshot.as_mut()?; let unstaged_diff_snapshot = secondary_diff.read_with(cx, |secondary_diff, _cx| { secondary_diff.diff_snapshot.clone() })?; - let new_index_text = diff_snapshot.stage_or_unstage_hunks_impl( + let diff_snapshot = self.diff_snapshot.clone()?; + let (edits, pending) = diff_snapshot.compute_uncommitted_index_edits( &unstaged_diff_snapshot, stage, hunks, buffer, file_exists, ); - - cx.emit(BufferDiffEvent::HunksStagedOrUnstaged( - new_index_text.clone(), - )); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } - new_index_text + self.set_pending_hunks(&pending, buffer, cx); + edits.map(|edits| { + let mut index_text = unstaged_diff_snapshot.base_text.as_rope().clone(); + for (old_range, replacement_text) in edits.iter().rev() { + index_text.replace(old_range.clone(), replacement_text); + } + index_text + }) } + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_all_hunks( &mut self, stage: bool, buffer: &text::BufferSnapshot, file_exists: bool, cx: &mut Context, - ) { + ) -> Option { let hunks = self .snapshot(cx) .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) .collect::>(); - let Some(diff_snapshot) = &mut self.diff_snapshot else { - return; - }; - let Some(secondary) = self.secondary_diff.clone() else { - return; - }; - let secondary = secondary.read(cx); - let Some(secondary_snapshot) = &secondary.diff_snapshot else { - return; - }; - diff_snapshot.stage_or_unstage_hunks_impl( - &secondary_snapshot, - stage, - &hunks, - buffer, - file_exists, - ); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } + self.stage_or_unstage_hunks(stage, &hunks, buffer, file_exists, cx) } pub fn update_diff( @@ -1916,6 +2122,12 @@ impl BufferDiff { .is_some_and(|diff_snapshot| diff_snapshot.base_text_exists) } + pub fn changed_row_counts(&self) -> (u32, u32) { + self.diff_snapshot + .as_ref() + .map_or((0, 0), |diff_snapshot| diff_snapshot.changed_row_counts()) + } + pub fn snapshot(&self, cx: &App) -> BufferDiffSnapshot { let mut snapshot = self.diff_snapshot.clone().unwrap_or_else(|| { let base_text = self.base_text_buffer.read(cx).snapshot(); @@ -2837,6 +3049,81 @@ mod tests { }); } + #[gpui::test] + async fn test_set_pending_hunks_change_covers_replaced_hunks(cx: &mut TestAppContext) { + let base_text = " + zero + one + two + three + four + five + " + .unindent(); + let buffer_text = " + ZERO + one + two + THREE + four + FIVE + " + .unindent(); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer, cx)); + + // Install a whole-file pending hunk, as the no-HEAD staging paths do. + let version = buffer.version(); + diff.update(cx, |diff, cx| { + diff.set_pending_hunks( + &[PendingHunk::new( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + 0..base_text.len(), + version.clone(), + PendingSense::SetSecondaryStatus { stage: true }, + )], + &buffer, + cx, + ) + }); + + let (tx, rx) = mpsc::channel(); + let subscription = + cx.update(|cx| cx.subscribe(&diff, move |_, event, _| tx.send(event.clone()).unwrap())); + + // Replace it with a narrower hunk; the emitted change must still cover + // the whole extent of the replaced hunk. + diff.update(cx, |diff, cx| { + diff.set_pending_hunks( + &[PendingHunk::new( + buffer.anchor_before(Point::new(3, 0))..buffer.anchor_before(Point::new(4, 0)), + base_text.find("three").unwrap()..base_text.find("four").unwrap(), + version, + PendingSense::Suppress, + )], + &buffer, + cx, + ) + }); + + drop(subscription); + let events = rx.into_iter().collect::>(); + match events.as_slice() { + [ + BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range), + .. + }), + ] => { + assert_eq!( + changed_range.to_point(&buffer), + Point::zero()..buffer.max_point(), + ); + } + _ => panic!("unexpected events: {:?}", events), + } + } + #[gpui::test] async fn test_buffer_diff_compare(cx: &mut TestAppContext) { let base_text = " diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index 6b87c4d85552c5..0464dd8c0f3c2b 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -35,10 +35,8 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { return; }; - let active_call_handle = active_call_handle.clone(); - cx.subscribe_in( - &cx.entity(), - window, + cx.subscribe_in(&cx.entity(), window, { + let active_call_handle = active_call_handle.clone(); move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| { if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged { .. }) && window.is_window_active() @@ -52,8 +50,33 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { }) { task.detach_and_log_err(cx); } - }, - ) + } + }) + .detach(); + + // The user's call location used to be maintained by the per-workspace + // `TitleBar` (which observed window activation), but the title bar + // chrome moved into the sidebar and is no longer created for every + // window, so track window activation here instead. + cx.observe_window_activation(window, { + let active_call_handle = active_call_handle.clone(); + move |multi_workspace, window, cx| { + let task = if window.is_window_active() { + let project = multi_workspace.workspace().read(cx).project().clone(); + active_call_handle.update(cx, |active_call, cx| { + active_call.set_location(Some(&project), cx) + }) + } else if cx.active_window().is_none() { + active_call_handle + .update(cx, |active_call, cx| active_call.set_location(None, cx)) + } else { + return; + }; + if let Ok(task) = task { + task.detach_and_log_err(cx); + } + } + }) .detach(); }) .detach(); diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 0823f1fafee092..d7ad0b347ed3f4 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -16,13 +16,14 @@ pub struct IpcHandshake { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum OpenBehavior { - /// Consult the user's `cli_default_open_behavior` setting to choose between - /// `ExistingWindow` or `Classic`. + /// Consult the user's `cli_default_open_behavior` setting. #[default] Default, /// Always create a new window. No matching against existing worktrees. /// Corresponds to `zed -n`. AlwaysNew, + /// Create a new window unless opening a subpath of an existing project. + PreferNewWindow, /// Match broadly including subdirectories, and fall back to any existing /// window if no worktree matched. Corresponds to `zed -a`. Add, @@ -47,9 +48,7 @@ pub enum OpenBehavior { pub enum CliBehaviorSetting { /// Open directories as a new workspace in the current Zed window's sidebar. ExistingWindow, - /// Classic behavior: open directories in a new window, but reuse an - /// existing window when opening files that are already part of an open - /// project. + /// Open paths in a new window unless they are subpaths of an existing project. NewWindow, } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 565f97ff230555..82bec80e04da1d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -202,6 +202,13 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result { .map(|path_with_pos| path_with_pos.to_string(&|path| path.to_string_lossy().into_owned())) } +/// Returns whether a `--diff` argument refers to an existing path, allowing a +/// trailing `:line:column` suffix (parsed later by the Zed side, matching how +/// regular `zed path:line:column` arguments are handled). +fn diff_path_exists(diff_path: &str) -> bool { + Path::new(diff_path).exists() || PathWithPosition::parse_str(diff_path).path.exists() +} + fn expand_directory_diff_pairs( diff_pairs: Vec<[String; 2]>, ) -> anyhow::Result<(Vec<[String; 2]>, Vec)> { @@ -642,7 +649,7 @@ fn run() -> Result<()> { let right = parse_path_with_position(&path[1])?; for diff_path in [&left, &right] { anyhow::ensure!( - Path::new(diff_path).exists(), + diff_path_exists(diff_path), "--diff path does not exist: {diff_path}" ); } diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 0f7ee3174c3248..771a0f56052e46 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -1077,7 +1077,7 @@ mod tests { .enumerate() .filter_map(|(i, path)| { Some(( - Arc::from(RelPath::unix(path).ok()?), + Arc::from(RelPath::from_unix_str(path).ok()?), ProjectEntryId::from_proto(i as u64 + 1), PathChange::Added, )) diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index 858bf499cd5a72..1770bce23022af 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -240,7 +240,8 @@ pub fn make_get_authenticated_user_response( ) -> GetAuthenticatedUserResponse { GetAuthenticatedUserResponse { user: AuthenticatedUser { - id: user_id, + id_v2: format!("user_{user_id}"), + legacy_user_id: user_id, metrics_id: format!("metrics-id-{user_id}"), username: username.clone(), avatar_url: "".to_string(), diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 898a810e533406..53b5720f53bfb8 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,5 +1,5 @@ use super::{Client, Status, TypedEnvelope, proto}; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use cloud_api_client::websocket_protocol::MessageToClient; use cloud_api_client::{ @@ -30,6 +30,8 @@ use util::{ResultExt, TryFutureExt as _}; pub type LegacyUserId = u64; +pub const MAX_ORGANIZATION_NAME_LENGTH: usize = 100; + #[derive( Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize, )] @@ -144,6 +146,7 @@ pub enum Event { PrivateUserInfoUpdated, PlanUpdated, OrganizationChanged, + OrganizationRenamed, } #[derive(Clone, Copy)] @@ -736,6 +739,91 @@ impl UserStore { &self.organizations } + pub fn rename_organization( + &mut self, + organization_id: OrganizationId, + new_name: &str, + cx: &mut Context, + ) -> Task> { + let new_name = new_name.trim().to_string(); + if let Err(error) = self.validate_organization_rename(&organization_id, &new_name) { + return Task::ready(Err(error)); + } + + let Some(client) = self.client.upgrade() else { + return Task::ready(Err(anyhow!("client was dropped"))); + }; + let cloud_client = client.cloud_client(); + + cx.spawn(async move |this, cx| { + let renamed_organization = cloud_client + .rename_organization(&organization_id, &new_name) + .await + .context("failed to rename organization")?; + this.update(cx, |this, cx| { + this.apply_organization_rename(Arc::new(renamed_organization), cx) + })? + }) + } + + fn validate_organization_rename( + &self, + organization_id: &OrganizationId, + new_name: &str, + ) -> Result<()> { + anyhow::ensure!(!new_name.is_empty(), "Organization name cannot be empty."); + anyhow::ensure!( + new_name.chars().count() <= MAX_ORGANIZATION_NAME_LENGTH, + "Organization name cannot be longer than {MAX_ORGANIZATION_NAME_LENGTH} characters." + ); + + let organization = self + .organizations + .iter() + .find(|organization| organization.id == *organization_id) + .with_context(|| format!("Organization {} not found.", organization_id.0))?; + anyhow::ensure!( + organization.name.as_ref() != new_name, + "The new name is the same as the current name." + ); + + let collides_with_other_organization = self + .organizations + .iter() + .any(|other| other.id != *organization_id && other.name.eq_ignore_ascii_case(new_name)); + anyhow::ensure!( + !collides_with_other_organization, + "An organization named \"{new_name}\" already exists." + ); + + Ok(()) + } + + fn apply_organization_rename( + &mut self, + renamed_organization: Arc, + cx: &mut Context, + ) -> Result<()> { + let organization = self + .organizations + .iter_mut() + .find(|organization| organization.id == renamed_organization.id) + .context("renamed organization is no longer present")?; + *organization = renamed_organization.clone(); + + if self + .current_organization + .as_ref() + .is_some_and(|current| current.id == renamed_organization.id) + { + self.current_organization = Some(renamed_organization); + } + + cx.emit(Event::OrganizationRenamed); + cx.notify(); + Ok(()) + } + pub fn plan_for_organization(&self, organization_id: &OrganizationId) -> Option { self.plans_by_organization.get(organization_id).copied() } @@ -1083,3 +1171,183 @@ impl EditPredictionUsage { )?)) } } + +#[cfg(test)] +mod tests { + use super::*; + use clock::FakeSystemClock; + use cloud_api_client::{RenameOrganizationBody, RenameOrganizationResponse}; + use futures::AsyncReadExt as _; + use gpui::TestAppContext; + use http_client::FakeHttpClient; + use settings::SettingsStore; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + fn organization(id: &str, name: &str, is_personal: bool) -> Arc { + Arc::new(Organization { + id: OrganizationId(id.into()), + name: name.into(), + is_personal, + }) + } + + fn setup_user_store( + user_store: &Entity, + cx: &mut TestAppContext, + ) -> Arc { + cx.run_until_parked(); + + user_store.update(cx, |user_store, _| { + user_store.organizations = vec![ + organization("org-1", "Personal", true), + organization("org-2", "Acme", false), + ]; + user_store.current_organization = user_store.organizations.first().cloned(); + }); + + let renamed_events = Arc::new(AtomicUsize::new(0)); + cx.update({ + let renamed_events = renamed_events.clone(); + let user_store = user_store.clone(); + move |cx| { + cx.subscribe(&user_store, move |_, event, _| { + if let Event::OrganizationRenamed = event { + renamed_events.fetch_add(1, Ordering::SeqCst); + } + }) + .detach(); + } + }); + + renamed_events + } + + #[gpui::test] + async fn test_rename_organization(cx: &mut TestAppContext) { + init_test(cx); + + let http_client = FakeHttpClient::create(|request| async move { + assert_eq!(request.method().as_str(), "PATCH"); + assert_eq!(request.uri().path(), "/client/organizations/org-1"); + + let mut body = String::new(); + request.into_body().read_to_string(&mut body).await?; + let rename_body: RenameOrganizationBody = serde_json::from_str(&body)?; + + let response = RenameOrganizationResponse { + organization: Organization { + id: OrganizationId("org-1".into()), + name: rename_body.name.into(), + is_personal: true, + }, + }; + Ok(http_client::Response::builder() + .status(200) + .body(serde_json::to_string(&response)?.into())?) + }); + let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + client + .cloud_client() + .set_credentials(1, "access-token".into()); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let renamed_events = setup_user_store(&user_store, cx); + + user_store + .update(cx, |user_store, cx| { + user_store.rename_organization( + OrganizationId("org-1".into()), + " My Workspace ", + cx, + ) + }) + .await + .unwrap(); + + user_store.read_with(cx, |user_store, _| { + assert_eq!(user_store.organizations[0].name.as_ref(), "My Workspace"); + assert_eq!(user_store.organizations[1].name.as_ref(), "Acme"); + let current_organization = user_store.current_organization.as_ref().unwrap(); + assert_eq!(current_organization.name.as_ref(), "My Workspace"); + assert!(current_organization.is_personal); + }); + assert_eq!(renamed_events.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + async fn test_rename_organization_rejects_invalid_names(cx: &mut TestAppContext) { + init_test(cx); + + let request_made = Arc::new(AtomicBool::new(false)); + let http_client = FakeHttpClient::create({ + let request_made = request_made.clone(); + move |_request| { + request_made.store(true, Ordering::SeqCst); + async move { + Ok(http_client::Response::builder() + .status(500) + .body("".into())?) + } + } + }); + let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + client + .cloud_client() + .set_credentials(1, "access-token".into()); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let renamed_events = setup_user_store(&user_store, cx); + + let rename = |new_name: String, cx: &mut TestAppContext| { + user_store.update(cx, |user_store, cx| { + user_store.rename_organization(OrganizationId("org-1".into()), &new_name, cx) + }) + }; + + let error = rename(" ".into(), cx).await.unwrap_err(); + assert!(error.to_string().contains("empty"), "{error}"); + + let error = rename("x".repeat(MAX_ORGANIZATION_NAME_LENGTH + 1), cx) + .await + .unwrap_err(); + assert!(error.to_string().contains("longer than"), "{error}"); + + let error = rename("Personal".into(), cx).await.unwrap_err(); + assert!( + error.to_string().contains("same as the current name"), + "{error}" + ); + + let error = rename("acme".into(), cx).await.unwrap_err(); + assert!(error.to_string().contains("already exists"), "{error}"); + + let error = user_store + .update(cx, |user_store, cx| { + user_store.rename_organization(OrganizationId("org-404".into()), "New Name", cx) + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("not found"), "{error}"); + + user_store.read_with(cx, |user_store, _| { + assert_eq!(user_store.organizations[0].name.as_ref(), "Personal"); + assert_eq!(user_store.organizations[1].name.as_ref(), "Acme"); + assert_eq!( + user_store + .current_organization + .as_ref() + .unwrap() + .name + .as_ref(), + "Personal" + ); + }); + assert_eq!(renamed_events.load(Ordering::SeqCst), 0); + assert!(!request_made.load(Ordering::SeqCst)); + } +} diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 0473ff3932aa56..d9cd62552265d4 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -69,6 +69,27 @@ pub fn skills_docs(cx: &App) -> String { format!("{docs_url}/ai/skills", docs_url = docs_url(cx)) } +/// Returns the URL to Zed's Agent sandboxing documentation. +/// +/// Pass `section` to deep-link to a specific section anchor on the page (for +/// example, `Some("installing-bubblewrap")`); pass `None` to link to the top of +/// the page. +/// +/// Unlike the account/app links above, this targets `zed.dev/docs` (via +/// [`release_channel::docs_url`]) rather than the configured `server_url`: the +/// docs are a static site hosted on `zed.dev`, so pointing at a local dev +/// `server_url` would 404. +pub fn sandboxing_docs(section: Option<&str>, cx: &App) -> String { + let base = release_channel::docs_url("ai/sandboxing", cx); + match section { + Some(section) => format!("{base}#{section}"), + None => base, + } +} +pub fn llm_provider_docs(cx: &App) -> String { + format!("{docs_url}/ai/llm-providers", docs_url = docs_url(cx)) +} + /// Returns the URL to Zed's ACP registry blog post. pub fn acp_registry_blog(cx: &App) -> String { format!( diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index e2f9d69002e236..a12a76d8b26f54 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -87,7 +87,7 @@ impl CloudApiClient { *self.credentials.write() = None; } - fn cloud_host(&self) -> String { + pub fn cloud_host(&self) -> String { self.http_client .build_zed_cloud_url("/") .ok() @@ -95,16 +95,6 @@ impl CloudApiClient { .unwrap_or_else(|| "cloud.zed.dev".into()) } - fn build_request( - &self, - req: request::Builder, - body: impl Into, - ) -> Result, ClientApiError> { - let credentials = self.credentials.read(); - let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; - build_request(req, body, credentials).map_err(ClientApiError::RequestBuildFailed) - } - pub async fn get_authenticated_user( &self, system_id: Option, @@ -121,8 +111,8 @@ impl CloudApiClient { builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id) }); - let request = self.build_request(request_builder, AsyncBody::default())?; - self.send_authenticated_json_request(request).await + self.send_authenticated_json_request(request_builder, AsyncBody::default()) + .await } pub fn connect(&self, cx: &App) -> Result>> { @@ -171,11 +161,11 @@ impl CloudApiClient { builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id) }); - let request = self.build_request( + self.send_authenticated_json_request( request_builder, Json(CreateLlmTokenBody { organization_id }), - )?; - self.send_authenticated_json_request(request).await + ) + .await } pub async fn update_system_settings( @@ -193,22 +183,57 @@ impl CloudApiClient { ) .header(ZED_SYSTEM_ID_HEADER_NAME, system_id); - let request = self.build_request(request_builder, Json(body))?; - self.send_authenticated_json_request(request).await + self.send_authenticated_json_request(request_builder, Json(body)) + .await } - async fn send_authenticated_json_request( + pub async fn rename_organization( &self, - request: Request, + organization_id: &OrganizationId, + name: &str, + ) -> Result { + let request_builder = Request::builder().method(Method::PATCH).uri( + self.http_client + .build_zed_cloud_url(&format!("/client/organizations/{}", organization_id.0)) + .map_err(ClientApiError::RequestBuildFailed)? + .as_ref(), + ); + + let response: RenameOrganizationResponse = self + .send_authenticated_json_request( + request_builder, + Json(RenameOrganizationBody { + name: name.to_string(), + }), + ) + .await?; + + Ok(response.organization) + } + + pub async fn send_authenticated_json_request( + &self, + request_builder: request::Builder, + body: impl Into, ) -> Result { - let mut response = self.send_authenticated_request(request).await?; + let mut response = self + .send_authenticated_request(request_builder, body) + .await?; Self::read_response_json(&mut response).await } async fn send_authenticated_request( &self, - request: Request, + request_builder: request::Builder, + body: impl Into, ) -> Result, ClientApiError> { + let request = { + let credentials = self.credentials.read(); + let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; + build_request(request_builder, body, credentials) + .map_err(ClientApiError::RequestBuildFailed)? + }; + let host = self.cloud_host(); let mut response = self.http_client.send(request).await.map_err(|source| { ClientApiError::ConnectionFailed { @@ -285,16 +310,14 @@ impl CloudApiClient { } pub async fn submit_agent_feedback(&self, body: SubmitAgentThreadFeedbackBody) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/agent_thread")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; - - self.send_authenticated_request(request).await?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/agent_thread")? + .as_ref(), + ); + + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -302,16 +325,14 @@ impl CloudApiClient { &self, body: SubmitAgentThreadFeedbackCommentsBody, ) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/agent_thread_comments")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; - - self.send_authenticated_request(request).await?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/agent_thread_comments")? + .as_ref(), + ); + + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -319,16 +340,14 @@ impl CloudApiClient { &self, body: SubmitEditPredictionFeedbackBody, ) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/edit_prediction")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; - - self.send_authenticated_request(request).await?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/edit_prediction")? + .as_ref(), + ); + + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } } diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index a06e0268c96f48..69a3bfd0f85a62 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -36,7 +36,8 @@ pub struct GetAuthenticatedUserResponse { #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct AuthenticatedUser { - pub id: i32, + pub id_v2: String, + pub legacy_user_id: i32, pub metrics_id: String, pub username: String, pub avatar_url: String, @@ -50,7 +51,7 @@ pub struct AuthenticatedUser { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] pub struct OrganizationId(pub Arc); -#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct Organization { pub id: OrganizationId, pub name: Arc, @@ -71,11 +72,6 @@ pub struct OrganizationEditPredictionConfiguration { pub is_feedback_enabled: bool, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct AcceptTermsOfServiceResponse { - pub user: AuthenticatedUser, -} - #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct LlmToken(pub String); @@ -99,6 +95,16 @@ pub struct SystemSettings { pub selected_organization_id: Option, } +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct RenameOrganizationBody { + pub name: String, +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct RenameOrganizationResponse { + pub organization: Organization, +} + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct SubmitAgentThreadFeedbackBody { pub organization_id: Option, diff --git a/crates/cloud_api_types/src/internal_api.rs b/crates/cloud_api_types/src/internal_api.rs index 2a352a09da94d3..bfe47f9535c075 100644 --- a/crates/cloud_api_types/src/internal_api.rs +++ b/crates/cloud_api_types/src/internal_api.rs @@ -44,14 +44,14 @@ pub struct FuzzySearchUsersResponse { } #[derive(Debug, Serialize, Deserialize)] -pub struct FuzzySearchChannelMembersByGithubLoginBody { +pub struct FuzzySearchChannelMembersBody { pub channel_id: i32, pub query: String, pub limit: u32, } #[derive(Debug, Serialize, Deserialize)] -pub struct FuzzySearchChannelMembersByGithubLoginResponse { +pub struct FuzzySearchChannelMembersResponse { pub channel_members: Vec, pub users: Vec, } diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index a33a2e8cc1a779..167507e86a389d 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -131,6 +131,11 @@ pub enum PredictEditsRequestTrigger { LSPCompletionAccepted, PredictionAccepted, PredictionPartiallyAccepted, + EditorCreated, + ProviderChanged, + UserInfoChanged, + VimModeChanged, + SettingsChanged, #[default] Other, } @@ -196,6 +201,10 @@ pub enum EditPredictionRejectReason { Empty, /// Edits returned, but none remained after interpolation InterpolatedEmpty, + /// Edits returned, but could not be interpolated after buffer changes + InterpolateFailed, + /// A patch was returned, but could not be applied to the buffer + PatchApplyFailed, /// The new prediction was preferred over the current one Replaced, /// The current prediction was preferred over the new one diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 64de772aec1a2b..c5889ae6663770 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -82,9 +82,10 @@ struct CurrentCompletion { impl CurrentCompletion { /// Attempts to adjust the edits based on changes made to the buffer since the completion was generated. - /// Returns None if the user's edits conflict with the predicted edits. + /// Returns None if no predicted edits remain or the user's edits conflict with the predicted edits. fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option, Arc)>> { edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) + .filter(|edits| !edits.is_empty()) } } diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index 95cb4626b3eb49..7f0fa5b7a8a30e 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -67,7 +67,7 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into .context("failed to parse response body")?; let user = User { - id: UserId(response_body.user.id), + id: UserId(response_body.user.legacy_user_id), username: response_body.user.username, github_login: response_body.user.github_login, avatar_url: response_body.user.avatar_url, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 60e49fc39a6091..786f750cd0c700 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -525,6 +525,8 @@ impl RejoinedProject { visible: worktree.visible, abs_path: worktree.abs_path.clone(), root_repo_common_dir: None, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, }) .collect(), collaborators: self @@ -730,6 +732,10 @@ fn db_status_to_proto( }), diff_stat_added: entry.lines_added.map(|v| v as u32), diff_stat_deleted: entry.lines_deleted.map(|v| v as u32), + staged_diff_stat_added: None, + staged_diff_stat_deleted: None, + unstaged_diff_stat_added: None, + unstaged_diff_stat_deleted: None, }) } diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 3cf82e8518cb14..add702ff23b4e4 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -961,7 +961,7 @@ impl Database { let path_style = if project.windows_paths { PathStyle::Windows } else { - PathStyle::Posix + PathStyle::Unix }; let features: Vec = serde_json::from_str(&project.features).unwrap_or_default(); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index c0ea34bf3aad70..5c30a49afabf9d 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -406,6 +406,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler( forward_mutating_project_request::, @@ -480,8 +482,12 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) - .add_request_handler(forward_read_only_project_request::) - .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler( + forward_mutating_project_request::, + ) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) @@ -1599,6 +1605,8 @@ fn notify_rejoined_projects( abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, root_repo_common_dir: worktree.root_repo_common_dir, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, updated_entries: worktree.updated_entries, removed_entries: worktree.removed_entries, scan_id: worktree.scan_id, @@ -2007,6 +2015,8 @@ async fn join_project( visible: worktree.visible, abs_path: worktree.abs_path.clone(), root_repo_common_dir: None, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, }) .collect::>(); @@ -2059,6 +2069,8 @@ async fn join_project( abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, root_repo_common_dir: worktree.root_repo_common_dir, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, updated_entries: worktree.entries, removed_entries: Default::default(), scan_id: worktree.scan_id, diff --git a/crates/collab/src/services/user_service.rs b/crates/collab/src/services/user_service.rs index abe22504afafc6..61541aa5a10dd8 100644 --- a/crates/collab/src/services/user_service.rs +++ b/crates/collab/src/services/user_service.rs @@ -1,10 +1,9 @@ use anyhow::{Context as _, anyhow}; use async_trait::async_trait; use cloud_api_types::internal_api::{ - self, FuzzySearchChannelMembersByGithubLoginBody, - FuzzySearchChannelMembersByGithubLoginResponse, FuzzySearchUsersBody, FuzzySearchUsersResponse, - LookUpUserByGithubLoginBody, LookUpUserByGithubLoginResponse, LookUpUsersByLegacyIdBody, - LookUpUsersByLegacyIdResponse, + self, FuzzySearchChannelMembersBody, FuzzySearchChannelMembersResponse, FuzzySearchUsersBody, + FuzzySearchUsersResponse, LookUpUserByGithubLoginBody, LookUpUserByGithubLoginResponse, + LookUpUsersByLegacyIdBody, LookUpUsersByLegacyIdResponse, }; use reqwest::RequestBuilder; use rpc::proto; @@ -157,14 +156,14 @@ impl UserService for CloudUserService { query: &str, limit: u32, ) -> Result<(Vec, Vec)> { - let response_body: FuzzySearchChannelMembersByGithubLoginResponse = self + let response_body: FuzzySearchChannelMembersResponse = self .send_request( self.http_client .post(format!( - "{}/internal/channel_members/fuzzy_search_by_github_login", + "{}/internal/channel_members/fuzzy_search", &self.zed_cloud_url )) - .json(&FuzzySearchChannelMembersByGithubLoginBody { + .json(&FuzzySearchChannelMembersBody { channel_id: channel.root_id().0, query: query.to_string(), limit, diff --git a/crates/collab/tests/integration/channel_tests.rs b/crates/collab/tests/integration/channel_tests.rs index 329478819e491d..7b20620082db6d 100644 --- a/crates/collab/tests/integration/channel_tests.rs +++ b/crates/collab/tests/integration/channel_tests.rs @@ -589,6 +589,95 @@ async fn test_channel_room( ); } +#[gpui::test] +async fn test_rejoining_channel_after_stale_connection_cleanup_connects_livekit( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_a2: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + active_call_a + .update(cx_a, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let old_room_a = + cx_a.read(|cx| active_call_a.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_a.read(|cx| old_room_a.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + + server.disconnect_client(client_a.peer_id().unwrap()); + executor.run_until_parked(); + server.advance_livekit_timestamp(); + + let client_a2 = server.create_client(cx_a2, "user_a").await; + let active_call_a2 = cx_a2.read(ActiveCall::global); + active_call_a2 + .update(cx_a2, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let room_a2 = + cx_a2.read(|cx| active_call_a2.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_a2.read(|cx| room_a2.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + assert_eq!( + room_participants(&room_a2, cx_a2), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: vec![] + } + ); + + let room_b = + cx_b.read(|cx| active_call_b.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_b.read(|cx| room_b.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: vec![] + } + ); + + cx_a2.read(|cx| { + client_a2.channel_store().read_with(cx, |channels, _| { + let mut participant_ids = channels + .channel_participants(channel_id) + .iter() + .map(|participant| participant.legacy_id) + .collect::>(); + participant_ids.sort_unstable(); + let mut expected_ids = vec![client_a2.user_id().unwrap(), client_b.user_id().unwrap()]; + expected_ids.sort_unstable(); + assert_eq!(participant_ids, expected_ids); + }) + }); +} + #[gpui::test] async fn test_channel_jumping(executor: BackgroundExecutor, cx_a: &mut TestAppContext) { let mut server = TestServer::start(executor.clone()).await; diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 28f9cd15758226..fd754dc49911da 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -4214,6 +4214,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA .into_iter() .map(|(sha, message)| (sha.parse().unwrap(), message.into())) .collect(), + tag_names: Default::default(), }; client_a.fs().set_blame_for_repo( Path::new(path!("/my-repo/.git")), @@ -6315,6 +6316,11 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.document_symbols = Some(DocumentSymbols::On); + settings + .editor + .toolbar + .get_or_insert_default() + .show_breadcrumb_symbols = Some(true); }); }); }); @@ -6338,6 +6344,11 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.document_symbols = Some(DocumentSymbols::On); + settings + .editor + .toolbar + .get_or_insert_default() + .show_breadcrumb_symbols = Some(true); }); }); }); diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index c4650971d2aabb..4dcd4bb3e58bfc 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -3473,7 +3473,7 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project.delete_entry(dir_entry.id, false, cx).unwrap() + project.delete_entry(dir_entry.id, cx).unwrap() }) .await .unwrap(); @@ -3501,7 +3501,7 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project.delete_entry(entry.id, false, cx).unwrap() + project.delete_entry(entry.id, cx).unwrap() }) .await .unwrap(); @@ -5139,6 +5139,109 @@ async fn test_definition( }); } +#[gpui::test] +async fn test_edit_prediction_definition( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + let capabilities = lsp::ServerCapabilities { + definition_provider: Some(OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "a.rs": "const ONE: usize = TWO;", + "b.rs": "const TWO: usize = 2;", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (buffer_b, _handle) = project_b + .update(cx_b, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("a.rs")), cx) + }) + .await + .unwrap(); + + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.set_request_handler::( + |_, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new( + lsp::Uri::from_file_path(path!("/root/b.rs")).unwrap(), + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + ), + ))) + }, + ); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let definitions = project_b + .update(cx_b, |project, cx| { + project.edit_prediction_definitions(&buffer_b, 19, false, cx) + }) + .await + .unwrap(); + + cx_b.read(|cx| { + assert_eq!(definitions.len(), 1); + assert_eq!( + definitions[0].path, + ProjectPath { + worktree_id, + path: rel_path("b.rs").into(), + } + ); + assert_eq!( + definitions[0].range.start.0, + language::PointUtf16::new(0, 6) + ); + assert_eq!(definitions[0].range.end.0, language::PointUtf16::new(0, 9)); + assert!( + project_b + .read(cx) + .get_open_buffer(&definitions[0].path, cx) + .is_none() + ); + }); +} + #[gpui::test(iterations = 10)] async fn test_references( executor: BackgroundExecutor, @@ -7310,6 +7413,20 @@ async fn test_remote_git_branches( }); assert_eq!(host_branch.name(), "totally-new-branch"); + + let default_branch_b = cx_b + .update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(false))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch_b.as_deref(), Some("main")); + + let default_branch_with_remote_b = cx_b + .update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(true))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch_with_remote_b.as_deref(), Some("origin/main")); } #[gpui::test] diff --git a/crates/collab/tests/integration/random_project_collaboration_tests.rs b/crates/collab/tests/integration/random_project_collaboration_tests.rs index c997f16ad31bff..391cef208d3e4f 100644 --- a/crates/collab/tests/integration/random_project_collaboration_tests.rs +++ b/crates/collab/tests/integration/random_project_collaboration_tests.rs @@ -448,7 +448,7 @@ impl RandomizedTest for ProjectCollaborationTest { .choose(rng) .unwrap(); if entry.path.as_ref().is_empty() { - worktree.root_name().into() + worktree.root_name().to_rel_path_buf() } else { worktree.root_name().join(&entry.path) } @@ -1524,7 +1524,7 @@ fn buffer_for_full_path( else { return false; }; - worktree.read(cx).root_name().join(&file.path()).as_ref() == full_path + worktree.read(cx).root_name().join(&file.path()) == *full_path }) }) .cloned() diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 62169c9a9389c6..3434674e630c58 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -48,7 +48,7 @@ use std::{ use util::path; use workspace::{MultiWorkspace, Workspace, WorkspaceStore}; -use livekit_client::test::TestServer as LivekitTestServer; +use livekit_client::test::{ManualUnixTimestampSource, TestServer as LivekitTestServer}; use crate::db_tests::TestDb; @@ -56,6 +56,7 @@ pub struct TestServer { pub app_state: Arc, pub test_livekit_server: Arc, pub test_db: TestDb, + livekit_timestamp_source: Arc, server: Arc, next_github_user_id: i32, connection_killers: Arc>>>, @@ -96,11 +97,13 @@ impl TestServer { TestDb::sqlite(deterministic.clone()) }; let livekit_server_id = NEXT_LIVEKIT_SERVER_ID.fetch_add(1, SeqCst); - let livekit_server = LivekitTestServer::create( + let livekit_timestamp_source = Arc::new(ManualUnixTimestampSource::new(1_234_567)); + let livekit_server = LivekitTestServer::create_with_timestamp_source( format!("http://livekit.{}.test", livekit_server_id), format!("devkey-{}", livekit_server_id), format!("secret-{}", livekit_server_id), deterministic.clone(), + livekit_timestamp_source.clone(), ) .unwrap(); let executor = Executor::Deterministic(deterministic.clone()); @@ -121,10 +124,15 @@ impl TestServer { forbid_connections: Default::default(), next_github_user_id: 0, test_db, + livekit_timestamp_source, test_livekit_server: livekit_server, } } + pub fn advance_livekit_timestamp(&self) { + self.livekit_timestamp_source.advance(); + } + pub async fn start2( cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index db2b44d3d20990..8cc6d75455db0b 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2964,8 +2964,9 @@ impl CollabPanel { Section::Channels => { Some( h_flex() + .gap_px() .child( - IconButton::new("filter-occupied-channels", IconName::ListFilter) + IconButton::new("filter-occupied-channels", IconName::OnCall) .icon_size(IconSize::Small) .toggle_state(self.filter_occupied_channels) .on_click(cx.listener(|this, _, _window, cx| { diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 3b8a6e770e4475..e1eddf01c282ce 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -126,7 +126,9 @@ impl CommandPalette { let picker = cx.new(|cx| { // One-shot action; there's nothing to reopen. - let picker = Picker::uniform_list(delegate, window, cx).reopenable(false, cx); + let picker = Picker::uniform_list(delegate, window, cx) + .reopenable(false, cx) + .show_scrollbar(true); picker.set_query(query, window, cx); picker }); diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index b8a321d01b50a7..3761f19ec4ae6e 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, channel::oneshot, future, select}; use futures_lite::future::yield_now; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task}; use parking_lot::Mutex; -use postage::barrier; +use postage::{barrier, prelude::Stream as _}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, value::RawValue}; use slotmap::SlotMap; @@ -21,6 +21,7 @@ use std::{ use util::{ResultExt, TryFutureExt}; use crate::{ + oauth::WwwAuthenticate, transport::{StdioTransport, Transport}, types::{CancelledParams, ClientNotification, Notification as _, notifications::Cancelled}, }; @@ -56,19 +57,19 @@ pub(crate) struct Client { #[allow(clippy::type_complexity)] #[allow(dead_code)] io_tasks: Mutex>, Task>)>>, - #[allow(dead_code)] output_done_rx: Mutex>, executor: BackgroundExecutor, - #[allow(dead_code)] transport: Arc, request_timeout: Option, /// Single-slot side channel for the last transport-level error. When the /// output task encounters a send failure it stashes the error here and - /// exits; the next request to observe cancellation `.take()`s it so it can - /// propagate a typed error (e.g. `TransportError::AuthRequired`) instead - /// of a generic "cancelled". This works because `initialize` is the sole - /// in-flight request at startup, but would need rethinking if concurrent - /// requests are ever issued during that phase. + /// exits; the next request to observe cancellation `.take()`s it so it + /// can fail with the underlying cause (e.g. "connection refused") instead + /// of a generic "cancelled". This is best-effort diagnostics: with + /// concurrent requests in flight, a single arbitrary one receives the + /// stashed error. Nothing may depend on it for correctness — + /// authentication challenges are observed via [`Self::wait_for_shutdown`] + /// and [`Transport::auth_challenge`], which do not involve requests. last_transport_error: Arc>>, } @@ -348,6 +349,28 @@ impl Client { Ok(()) } + /// A future that resolves once the transport's output loop has terminated + /// — after a send failure, or when this client is dropped — yielding the + /// authentication challenge recorded by the transport if it shut down on a + /// `401 Unauthorized` response. + /// + /// Unlike `last_transport_error`, this does not require a request to be in + /// flight when the transport fails. Returns `None` if the shutdown signal + /// was already claimed: there is a single signal per client. + pub(crate) fn wait_for_shutdown( + &self, + ) -> Option>> { + let mut output_done = self.output_done_rx.lock().take()?; + let transport = self.transport.clone(); + Some( + async move { + output_done.recv().await; + transport.auth_challenge() + } + .boxed(), + ) + } + /// Sends a JSON-RPC request to the context server and waits for a response. /// This function handles serialization, deserialization, timeout, and error handling. pub async fn request( diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 05a3451ea863a3..865f779b160599 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -21,6 +21,7 @@ use parking_lot::RwLock; pub use settings::ContextServerCommand; use url::Url; +use crate::oauth::WwwAuthenticate; use crate::transport::HttpTransport; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -106,6 +107,16 @@ impl ContextServer { self.client.read().clone() } + /// The authentication challenge from the last `401 Unauthorized` response + /// this server's transport gave up on, if any. See + /// [`crate::transport::Transport::auth_challenge`]. + pub fn auth_challenge(&self) -> Option { + match &self.configuration { + ContextServerTransport::Stdio(..) => None, + ContextServerTransport::Custom(transport) => transport.auth_challenge(), + } + } + pub async fn start(&self, cx: &AsyncApp) -> Result<()> { self.initialize(self.new_client(cx)?).await } diff --git a/crates/context_server/src/protocol.rs b/crates/context_server/src/protocol.rs index 05082637c276ff..6eb4a84a46cc90 100644 --- a/crates/context_server/src/protocol.rs +++ b/crates/context_server/src/protocol.rs @@ -8,11 +8,12 @@ use std::time::Duration; use anyhow::Result; -use futures::channel::oneshot; +use futures::{channel::oneshot, future::BoxFuture}; use gpui::AsyncApp; use serde_json::Value; use crate::client::{Client, NotificationSubscription}; +use crate::oauth::WwwAuthenticate; use crate::types::{self, Notification, Request}; pub struct ModelContextProtocol { @@ -122,6 +123,20 @@ impl InitializedContextServerProtocol { self.inner.notify(T::METHOD, params) } + /// A future that resolves once the underlying transport's output loop has + /// terminated — after a send failure, or when the client is dropped — + /// yielding the authentication challenge recorded by the transport if it + /// shut down on a `401 Unauthorized` response. + /// + /// Servers may accept `initialize` unauthenticated and only challenge a + /// later request or notification. Awaiting this is what lets the owner of + /// the connection notice such a challenge even when no request was in + /// flight to carry a typed error back. Returns `None` if the shutdown + /// signal was already claimed: there is a single signal per client. + pub fn wait_for_shutdown(&self) -> Option>> { + self.inner.wait_for_shutdown() + } + pub fn on_notification( &self, method: &'static str, diff --git a/crates/context_server/src/transport.rs b/crates/context_server/src/transport.rs index bffd7e4c4d84a8..edb8a5e8da026d 100644 --- a/crates/context_server/src/transport.rs +++ b/crates/context_server/src/transport.rs @@ -6,6 +6,8 @@ use async_trait::async_trait; use futures::Stream; use std::pin::Pin; +use crate::oauth::WwwAuthenticate; + pub use http::*; pub use stdio_transport::*; @@ -19,4 +21,16 @@ pub trait Transport: Send + Sync { /// need the negotiated version (currently only HTTP, which must attach an /// `MCP-Protocol-Version` header from 2025-06-18 onward) can pick it up. fn set_protocol_version(&self, _version: &str) {} + + /// The authentication challenge from the last `401 Unauthorized` response + /// this transport gave up on, if any (currently only set by the HTTP + /// transport). + /// + /// The challenge is recorded right before the failed send tears down the + /// client's output loop. Observers of the client's shutdown read it from + /// here, so a 401 can initiate the OAuth flow even when it arrived on a + /// notification, with no request in flight to carry a typed error. + fn auth_challenge(&self) -> Option { + None + } } diff --git a/crates/context_server/src/transport/http.rs b/crates/context_server/src/transport/http.rs index bf374586b35fb5..aa47a73fc383e6 100644 --- a/crates/context_server/src/transport/http.rs +++ b/crates/context_server/src/transport/http.rs @@ -58,6 +58,10 @@ pub struct HttpTransport { /// When set, the transport attaches `Authorization: Bearer` headers and /// handles 401 responses with token refresh + retry. token_provider: Option>, + /// The challenge from the last 401 this transport gave up on; cleared at + /// the start of each send so it always describes the most recent attempt. + /// See [`Transport::auth_challenge`]. + auth_challenge: SyncMutex>, } impl HttpTransport { @@ -92,6 +96,7 @@ impl HttpTransport { error_rx, headers, token_provider, + auth_challenge: SyncMutex::new(None), } } @@ -133,8 +138,21 @@ impl HttpTransport { Ok(request_builder.body(AsyncBody::from(message.to_vec()))?) } + /// Record the challenge so it remains observable after the failed send + /// tears down the client (see [`Transport::auth_challenge`]), and build + /// the typed error for the send itself. + fn auth_required(&self, www_authenticate: WwwAuthenticate) -> anyhow::Error { + *self.auth_challenge.lock() = Some(www_authenticate.clone()); + TransportError::AuthRequired { www_authenticate }.into() + } + /// Send a message and handle the response based on content type. async fn send_message(&self, message: String) -> Result<()> { + // The same server instance can be restarted over this transport; a + // challenge recorded by a previous client generation must not be + // observed by the current one. + *self.auth_challenge.lock() = None; + let is_notification = !message.contains("\"id\":") || message.contains("notifications/initialized"); @@ -174,13 +192,13 @@ impl HttpTransport { // If still 401 after refresh, give up. if response.status().as_u16() == 401 { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } else { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } else { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } @@ -335,6 +353,10 @@ impl Transport for HttpTransport { fn set_protocol_version(&self, version: &str) { *self.protocol_version.lock() = Some(version.to_string()); } + + fn auth_challenge(&self) -> Option { + self.auth_challenge.lock().clone() + } } impl Drop for HttpTransport { diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 0d9d9ed1e61ab1..3b73bacb2e0037 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -48,7 +48,7 @@ util.workspace = true workspace.workspace = true [target.'cfg(windows)'.dependencies] -async-std = { version = "1.12.0", features = ["unstable"] } +async-std.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index d295d94198f5b7..85446c47928000 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -283,7 +283,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); cx.set_state(indoc! {" @@ -491,7 +496,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); // Setup the editor with a completion request. @@ -623,7 +633,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); cx.set_state(indoc! {" @@ -718,7 +733,12 @@ mod tests { }); let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor.update_in(cx, |editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); handle_copilot_completion_request( @@ -848,7 +868,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); cx.set_state(indoc! {" @@ -1014,7 +1039,12 @@ mod tests { let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }) .unwrap(); diff --git a/crates/copilot_ui/Cargo.toml b/crates/copilot_ui/Cargo.toml index 14b9fe436791eb..ba5935c65edf62 100644 --- a/crates/copilot_ui/Cargo.toml +++ b/crates/copilot_ui/Cargo.toml @@ -28,6 +28,7 @@ log.workspace = true lsp.workspace = true menu.workspace = true project.workspace = true +release_channel.workspace = true serde_json.workspace = true settings.workspace = true ui.workspace = true diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 28f8b25e0f11d0..5741d61348538f 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -9,6 +9,7 @@ use gpui::{ Subscription, TaskExt, Window, WindowBounds, WindowOptions, div, point, }; use project::project_settings::ProjectSettings; +use release_channel::ReleaseChannel; use settings::Settings as _; use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; use util::ResultExt as _; @@ -55,12 +56,13 @@ pub fn reinstall_and_sign_in(copilot: Entity, window: &mut Window, cx: fn open_copilot_code_verification_window(copilot: &Entity, window: &Window, cx: &mut App) { let current_window_center = window.bounds().center(); - let height = px(450.); - let width = px(350.); + let width = px(450.); + let height = px(350.); let window_bounds = WindowBounds::Windowed(gpui::bounds( - current_window_center - point(height / 2.0, width / 2.0), - gpui::size(height, width), + current_window_center - point(width / 2.0, height / 2.0), + gpui::size(width, height), )); + let app_id = ReleaseChannel::global(cx).app_id(); cx.open_window( WindowOptions { kind: gpui::WindowKind::Floating, @@ -68,9 +70,11 @@ fn open_copilot_code_verification_window(copilot: &Entity, window: &Win is_resizable: false, is_movable: true, titlebar: Some(gpui::TitlebarOptions { + title: Some("Use GitHub Copilot in Zed".into()), appears_transparent: true, ..Default::default() }), + app_id: Some(app_id.to_owned()), ..Default::default() }, |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), @@ -466,6 +470,10 @@ pub struct ConfigurationView { copilot_status: Option, is_authenticated: Box bool + 'static>, edit_prediction: bool, + /// When `true`, renders a compact control suitable for an inline settings + /// row: no explanatory labels (those live in the row's left column) and + /// content-sized buttons instead of full-width ones. + compact: bool, _subscription: Option, } @@ -487,6 +495,7 @@ impl ConfigurationView { copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()), is_authenticated: Box::new(is_authenticated), edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + compact: false, _subscription: copilot.as_ref().map(|copilot| { cx.observe(&copilot.0, |this, model, cx| { this.copilot_status = Some(model.read(cx).status()); @@ -495,6 +504,14 @@ impl ConfigurationView { }), } } + + /// Renders the view compactly for an inline settings row (no labels, + /// content-sized buttons). The explanatory copy is expected to be shown + /// elsewhere (e.g. the row's left column). + pub fn compact(mut self) -> Self { + self.compact = true; + self + } } impl ConfigurationView { @@ -536,34 +553,34 @@ impl ConfigurationView { edit_prediction: bool, ) -> impl IntoElement { Button::new("loading_button", label) - .full_width() + .map(|this| { + if edit_prediction || self.compact { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) .disabled(true) .loading(true) .style(ButtonStyle::Outlined) - .when(edit_prediction, |this| this.size(ButtonSize::Medium)) } fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement { let label = if edit_prediction { "Sign in to GitHub" } else { - "Sign in to use GitHub Copilot" + "Sign In" }; Button::new("sign_in", label) .map(|this| { - if edit_prediction { + if edit_prediction || self.compact { this.size(ButtonSize::Medium) } else { this.full_width() } }) .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Github) - .size(IconSize::Small) - .color(Color::Muted), - ) .when(edit_prediction, |this| this.tab_index(0isize)) .on_click(|_, window, cx| { let app_state = AppState::global(cx); @@ -582,7 +599,7 @@ impl ConfigurationView { Button::new("reinstall_and_sign_in", label) .map(|this| { - if edit_prediction { + if edit_prediction || self.compact { this.size(ButtonSize::Medium) } else { this.full_width() @@ -656,31 +673,32 @@ impl ConfigurationView { let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider."; - if let Some(msg) = self.loading_message() { - v_flex() - .gap_2() - .child(Label::new(start_label)) - .child(self.render_loading_button(msg, false)) - .into_any_element() + let (label, button) = if let Some(msg) = self.loading_message() { + ( + start_label, + self.render_loading_button(msg, false).into_any_element(), + ) } else if self.is_error() { - v_flex() - .gap_2() - .child(Label::new(ERROR_LABEL)) - .child(self.render_reinstall_button(false)) - .into_any_element() + ( + ERROR_LABEL, + self.render_reinstall_button(false).into_any_element(), + ) } else if self.has_no_status() { - v_flex() - .gap_2() - .child(Label::new(no_status_label)) - .child(self.render_sign_in_button(false)) - .into_any_element() + ( + no_status_label, + self.render_sign_in_button(false).into_any_element(), + ) } else { - v_flex() - .gap_2() - .child(Label::new(start_label)) - .child(self.render_sign_in_button(false)) - .into_any_element() - } + ( + start_label, + self.render_sign_in_button(false).into_any_element(), + ) + }; + + v_flex() + .gap_2() + .when(!self.compact, |this| this.child(Label::new(label))) + .child(button) } } @@ -689,13 +707,15 @@ impl Render for ConfigurationView { let is_authenticated = &self.is_authenticated; if is_authenticated(cx) { - return ConfiguredApiCard::new("Authorized") + let sign_out = |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + if let Some(auth) = GlobalCopilotAuth::try_global(cx) { + initiate_sign_out(auth.0.clone(), window, cx); + } + }; + + return ConfiguredApiCard::new("copilot-authorized", "Authorized") .button_label("Sign Out") - .on_click(|_, window, cx| { - if let Some(auth) = GlobalCopilotAuth::try_global(cx) { - initiate_sign_out(auth.0.clone(), window, cx); - } - }) + .on_click(sign_out) .into_any_element(); } diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index f8b898112c1881..1b118097cb4708 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -16,6 +16,9 @@ serde_json.workspace = true system_specs.workspace = true zstd.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true + [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 28217496cadb78..5d3fc954d308d7 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -138,6 +138,17 @@ where info!("crash signal handlers installed"); send_crash_server_message(&client, CrashServerMessage::Init(crash_init)); + #[cfg(all(target_os = "linux", target_env = "gnu"))] + if let Some(address) = abort_message_address() { + send_crash_server_message( + &client, + CrashServerMessage::AbortMessageLocation(AbortMessageLocation { + pid: process::id(), + address, + }), + ); + } + #[cfg(target_os = "linux")] handler.set_ptracer(Some(_crash_handler.id())); @@ -170,6 +181,7 @@ pub struct CrashServer { panic_info: Mutex>, active_gpu: Mutex>, user_info: Mutex>, + abort_message_location: Mutex>, has_connection: Arc, logs_dir: PathBuf, } @@ -179,11 +191,27 @@ pub struct CrashInfo { pub init: InitCrashHandler, pub panic: Option, pub minidump_error: Option, + /// The diagnostic the C runtime recorded before aborting the process, e.g. + /// glibc's "free(): invalid pointer". Only present when the crash was a + /// runtime-initiated abort rather than a signal like SIGSEGV or a panic. + #[serde(default)] + pub abort_message: Option, pub gpus: Vec, pub active_gpu: Option, pub user_info: Option, } +/// Where to find the C runtime's abort diagnostic in the crashed process's +/// memory. Sent by the client at startup so that after a crash the server can +/// recover the message with `process_vm_readv`; the crashed process itself +/// can't safely do this work, since its heap may be corrupt and its allocator +/// locks may be held by the crashed thread. +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +pub struct AbortMessageLocation { + pub pid: u32, + pub address: u64, +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct InitCrashHandler { pub session_id: String, @@ -233,6 +261,110 @@ enum CrashServerMessage { Panic(CrashPanic), GPUInfo(GpuSpecs), UserInfo(UserInfo), + AbortMessageLocation(AbortMessageLocation), +} + +/// glibc records the diagnostic it prints just before aborting (malloc integrity +/// failures like "free(): invalid pointer", assertion failures, stack-smashing +/// reports) in the private global `__abort_msg`, specifically so it can be +/// recovered post-mortem. Resolve its address here, in a safe context at startup. +/// The symbol is only exported at the GLIBC_PRIVATE version, which plain `dlsym` +/// won't resolve, and it has no stability guarantee, so a null result (e.g. musl, +/// or a future glibc removing it) just disables this diagnostic. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn abort_message_address() -> Option { + let ptr = unsafe { + libc::dlvsym( + libc::RTLD_DEFAULT, + c"__abort_msg".as_ptr(), + c"GLIBC_PRIVATE".as_ptr(), + ) + }; + std::ptr::NonNull::new(ptr).map(|ptr| ptr.as_ptr() as u64) +} + +/// Read the crashed process's abort diagnostic. `__abort_msg` points to a +/// `struct abort_msg_s { unsigned int size; char msg[]; }` that glibc allocates +/// with mmap so that it stays intact even when the heap is corrupt. `size` is +/// the total byte size of that mapping (header included, rounded up to whole +/// pages), not the message length; the message itself is NUL-terminated. +#[cfg(target_os = "linux")] +fn read_abort_message(location: AbortMessageLocation) -> Option { + let pointer_bytes = read_process_memory(location.pid, location.address, size_of::())?; + let message_address = usize::from_ne_bytes(pointer_bytes.try_into().ok()?) as u64; + if message_address == 0 { + return None; + } + let size_bytes = read_process_memory(location.pid, message_address, size_of::())?; + let size = u32::from_ne_bytes(size_bytes.try_into().ok()?); + let message_bytes = read_process_memory( + location.pid, + message_address + size_of::() as u64, + abort_message_read_len(size)?, + )?; + parse_abort_message(&message_bytes) +} + +/// How many message bytes to read given the `size` field of glibc's +/// `abort_msg_s`. `size` holds the total size of the mmap'd allocation, so a +/// value that isn't a whole number of pages means the layout has changed and +/// we shouldn't trust it. Reading is capped at (one page minus the header), +/// which both bounds the work and ensures the read never extends past the end +/// of the mapping. +#[cfg(any(target_os = "linux", test))] +fn abort_message_read_len(size: u32) -> Option { + // Every Linux page size (4 KiB, 16 KiB, 64 KiB, ...) is a multiple of 4 KiB. + const PAGE_MULTIPLE: usize = 4096; + const MAX_READ: usize = 4096; + + let size = size as usize; + if size == 0 || !size.is_multiple_of(PAGE_MULTIPLE) { + log::warn!("__abort_msg size field {size} is not page-rounded; layout may have changed"); + return None; + } + Some(size.min(MAX_READ) - size_of::()) +} + +/// The message is NUL-terminated inside a zero-filled mapping, so truncate at +/// the first NUL; `trim` alone would keep the padding, since NUL is not +/// whitespace. +#[cfg(any(target_os = "linux", test))] +fn parse_abort_message(bytes: &[u8]) -> Option { + let len = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + let message = String::from_utf8_lossy(&bytes[..len]).trim().to_string(); + (!message.is_empty()).then_some(message) +} + +#[cfg(target_os = "linux")] +fn read_process_memory(pid: u32, address: u64, len: usize) -> Option> { + let mut buffer = vec![0u8; len]; + let local = libc::iovec { + iov_base: buffer.as_mut_ptr().cast(), + iov_len: len, + }; + let remote = libc::iovec { + iov_base: address as *mut libc::c_void, + iov_len: len, + }; + let bytes_read = + unsafe { libc::process_vm_readv(pid as libc::pid_t, &local, 1, &remote, 1, 0) }; + if bytes_read < 0 { + log::warn!( + "process_vm_readv of {len} bytes at {address:#x} in pid {pid} failed: {}", + io::Error::last_os_error() + ); + return None; + } + if bytes_read as usize != len { + log::warn!( + "process_vm_readv short read at {address:#x} in pid {pid}: {bytes_read} of {len} bytes" + ); + return None; + } + Some(buffer) } impl minidumper::ServerHandler for CrashServer { @@ -281,6 +413,14 @@ impl minidumper::ServerHandler for CrashServer { } }; + // The crashed process is still alive at this point: it stays parked in + // its signal handler until the server acknowledges the dump request, + // which happens after this callback returns. + #[cfg(target_os = "linux")] + let abort_message = (*self.abort_message_location.lock()).and_then(read_abort_message); + #[cfg(not(target_os = "linux"))] + let abort_message = None; + let crash_info = CrashInfo { init: self .initialization_params @@ -289,6 +429,7 @@ impl minidumper::ServerHandler for CrashServer { .expect("not initialized"), panic: self.panic_info.lock().clone(), minidump_error, + abort_message, active_gpu: self.active_gpu.lock().clone(), gpus, user_info: self.user_info.lock().clone(), @@ -320,6 +461,9 @@ impl minidumper::ServerHandler for CrashServer { CrashServerMessage::UserInfo(user_info) => { self.user_info.lock().replace(user_info); } + CrashServerMessage::AbortMessageLocation(location) => { + self.abort_message_location.lock().replace(location); + } } } @@ -523,6 +667,7 @@ pub fn crash_server(socket: &Path, logs_dir: PathBuf) { initialization_params: Mutex::default(), panic_info: Mutex::default(), user_info: Mutex::default(), + abort_message_location: Mutex::default(), has_connection, active_gpu: Mutex::default(), logs_dir, @@ -532,3 +677,99 @@ pub fn crash_server(socket: &Path, logs_dir: PathBuf) { ) .expect("failed to run server"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn abort_message_read_len_requires_page_rounded_total() { + assert_eq!(abort_message_read_len(0), None); + // A message length rather than a mapping total means the glibc layout + // has changed out from under us. + assert_eq!(abort_message_read_len(23), None); + assert_eq!(abort_message_read_len(4097), None); + // The read must stay within the mapping: one page minus the header. + assert_eq!(abort_message_read_len(4096), Some(4092)); + // Larger totals (long messages, larger page sizes) are clamped. + assert_eq!(abort_message_read_len(8192), Some(4092)); + assert_eq!(abort_message_read_len(65536), Some(4092)); + } + + #[test] + fn parse_abort_message_truncates_at_nul() { + let mut buffer = b"free(): invalid pointer\n\0".to_vec(); + buffer.resize(4092, 0); + assert_eq!( + parse_abort_message(&buffer), + Some("free(): invalid pointer".to_string()) + ); + } + + #[test] + fn parse_abort_message_handles_missing_nul() { + assert_eq!( + parse_abort_message(b"double free or corruption (out)"), + Some("double free or corruption (out)".to_string()) + ); + } + + #[test] + fn parse_abort_message_rejects_empty() { + assert_eq!(parse_abort_message(&[]), None); + assert_eq!(parse_abort_message(&[0; 16]), None); + assert_eq!(parse_abort_message(b"\n \0garbage after nul"), None); + } + + /// End-to-end check of `read_abort_message` against a synthetic + /// `abort_msg_s` in this very process (`process_vm_readv` may always read + /// one's own memory). The message page is followed by a `PROT_NONE` guard + /// page so the test fails if the read ever extends past the mapping glibc + /// would have allocated. + #[cfg(target_os = "linux")] + #[test] + fn read_abort_message_reads_glibc_layout_from_a_live_process() { + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize; + unsafe { + let mapping = libc::mmap( + std::ptr::null_mut(), + 2 * page_size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_ANON | libc::MAP_PRIVATE, + -1, + 0, + ); + assert_ne!(mapping, libc::MAP_FAILED); + assert_eq!( + libc::mprotect( + mapping.cast::().add(page_size).cast(), + page_size, + libc::PROT_NONE + ), + 0 + ); + + mapping.cast::().write(page_size as u32); + let message = b"free(): invalid pointer\n\0"; + std::ptr::copy_nonoverlapping( + message.as_ptr(), + mapping.cast::().add(size_of::()), + message.len(), + ); + + // Stands in for the `__abort_msg` global: a pointer variable whose + // address we hand to the reader. + let abort_msg: *mut libc::c_void = mapping; + let location = AbortMessageLocation { + pid: process::id(), + address: (&raw const abort_msg) as u64, + }; + assert_eq!( + read_abort_message(location), + Some("free(): invalid pointer".to_string()) + ); + + libc::munmap(mapping, 2 * page_size); + } + } +} diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 322777da042c2b..3f8504ceae355f 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -8,12 +8,12 @@ use std::{ time::{Duration, Instant}, }; -use crate::table_data_engine::TableDataEngine; +use crate::table_data_engine::{DisplayToDataMapping, TableDataEngine}; use ui::{ AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState, TableResizeBehavior, prelude::*, }; -use workspace::{Item, SplitDirection, Workspace}; +use workspace::{Item, Pane, Workspace}; use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent}; @@ -41,10 +41,18 @@ pub struct CsvPreviewView { pub(crate) table_interaction_state: Entity, pub(crate) column_widths: ColumnWidths, pub(crate) parsing_task: Option>>, + pub(crate) is_parsing: bool, + /// Background task computing the display-to-data mapping after a filter/sort change. + /// Stored here so that a new change cancels the previous in-flight computation. + pub(crate) filter_sort_task: Option>, pub(crate) settings: CsvPreviewSettings, /// Performance metrics for debugging and monitoring CSV operations. pub(crate) performance_metrics: PerformanceMetrics, pub(crate) list_state: gpui::ListState, + /// Cached row height, refreshed from the actual text line height on every render. + /// Used to size not-yet-rendered rows for the scrollbar without a full `.measure_all()` + /// pass, so it tracks the real row height instead of a hardcoded guess. + pub(crate) row_height: Pixels, /// Time when the last parsing operation ended, used for smart debouncing pub(crate) last_parse_end_time: Option, } @@ -85,62 +93,19 @@ impl CsvPreviewView { workspace.register_action_renderer(|div, _, _, cx| { div.when(cx.has_flag::(), |div| { div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| { - if let Some(editor) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .filter(|editor| Self::is_csv_file(editor, cx)) - { - let csv_preview = Self::new(&editor, cx); - workspace.active_pane().update(cx, |pane, cx| { - let existing = pane - .items_of_type::() - .find(|view| view.read(cx).active_editor_state.editor == editor); - if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { - pane.activate_item(idx, true, true, window, cx); - } else { - pane.add_item(Box::new(csv_preview), true, true, None, window, cx); - } - }); - cx.notify(); + if let Some(editor) = Self::resolve_active_item_as_csv_editor(workspace, cx) { + let pane = workspace.active_pane().clone(); + Self::open_preview_in_pane(editor, pane, window, cx); } })) .on_action(cx.listener( |workspace, _: &OpenPreviewToTheSide, window, cx| { - if let Some(editor) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .filter(|editor| Self::is_csv_file(editor, cx)) + if let Some(editor) = Self::resolve_active_item_as_csv_editor(workspace, cx) { - let csv_preview = Self::new(&editor, cx); - let pane = workspace - .find_pane_in_direction(SplitDirection::Right, cx) - .unwrap_or_else(|| { - workspace.split_pane( - workspace.active_pane().clone(), - SplitDirection::Right, - window, - cx, - ) - }); - pane.update(cx, |pane, cx| { - let existing = - pane.items_of_type::().find(|view| { - view.read(cx).active_editor_state.editor == editor - }); - if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { - pane.activate_item(idx, true, true, window, cx); - } else { - pane.add_item( - Box::new(csv_preview), - false, - false, - None, - window, - cx, - ); - } - }); - cx.notify(); + let pane = workspace.active_pane().clone(); + Self::open_preview_to_the_side_of_pane( + workspace, editor, pane, window, cx, + ); } }, )) @@ -148,7 +113,58 @@ impl CsvPreviewView { }); } - fn new(editor: &Entity, cx: &mut Context) -> Entity { + pub fn open_preview_in_pane( + editor: Entity, + pane: Entity, + window: &mut Window, + cx: &mut Context, + ) { + Self::activate_or_add_preview(editor, pane, true, window, cx); + } + + pub fn open_preview_to_the_side_of_pane( + workspace: &mut Workspace, + editor: Entity, + origin_pane: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx); + Self::activate_or_add_preview(editor, target_pane, false, window, cx); + } + + fn activate_or_add_preview( + editor: Entity, + pane: Entity, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let existing_view_idx = Self::find_existing_preview_item_idx(pane.read(cx), &editor, cx); + if let Some(existing_view_idx) = existing_view_idx { + pane.update(cx, |pane, cx| { + pane.activate_item(existing_view_idx, focus, focus, window, cx); + }); + } else { + let csv_preview = Self::new(&editor, window, cx); + pane.update(cx, |pane, cx| { + pane.add_item(Box::new(csv_preview), focus, focus, None, window, cx); + }); + } + cx.notify(); + } + + fn find_existing_preview_item_idx( + pane: &Pane, + editor: &Entity, + cx: &App, + ) -> Option { + pane.items_of_type::() + .find(|view| &view.read(cx).active_editor_state.editor == editor) + .and_then(|view| pane.index_for_item(&view)) + } + + fn new(editor: &Entity, window: &Window, 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::< @@ -169,6 +185,7 @@ impl CsvPreviewView { }, ); + let row_height = window.pixel_snap(window.line_height()); let mut view = CsvPreviewView { focus_handle: cx.focus_handle(), active_editor_state: EditorState { @@ -178,9 +195,12 @@ impl CsvPreviewView { table_interaction_state, column_widths: ColumnWidths::new(cx, 1), parsing_task: None, + is_parsing: false, + filter_sort_task: None, performance_metrics: PerformanceMetrics::default(), list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) - .measure_all(), + .with_uniform_item_height(row_height), + row_height, settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -194,22 +214,53 @@ impl CsvPreviewView { pub(crate) fn editor_state(&self) -> &EditorState { &self.active_editor_state } - pub(crate) fn apply_sort(&mut self) { - self.performance_metrics.record("Sort", || { - self.engine.apply_sort(); - }); + pub(crate) fn apply_sort(&mut self, cx: &mut Context) { + self.apply_filter_sort(cx); } - /// Update ordered indices when ordering or content changes - pub(crate) fn apply_filter_sort(&mut self) { - self.performance_metrics.record("Filter&sort", || { - self.engine.calculate_d2d_mapping(); - }); + pub fn clear_filters(&mut self, col: types::AnyColumn, cx: &mut Context) { + self.engine.clear_filters_for_col(col); + self.apply_filter_sort(cx); + } - // Update list state with filtered row count - let visible_rows = self.engine.d2d_mapping().visible_row_count(); - self.list_state = - gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all(); + pub fn toggle_filter( + &mut self, + col: types::AnyColumn, + value: Option, + cx: &mut Context, + ) { + if let Err(err) = self.engine.toggle_filter(col, value) { + log::error!("Failed to toggle filter: {err}"); + return; + } + self.apply_filter_sort(cx); + } + + /// Spawns a background task to recompute the display-to-data mapping after a filter or sort + /// change. Storing the task cancels any previous in-flight computation automatically. + pub(crate) fn apply_filter_sort(&mut self, cx: &mut Context) { + let contents = self.engine.contents.clone(); + let filter_stack = self.engine.filter_stack.clone(); + let sorting = self.engine.applied_sorting; + + self.filter_sort_task = Some(cx.spawn(async move |this, cx| { + let mapping = cx + .background_spawn(async move { + DisplayToDataMapping::compute(&contents, &filter_stack, sorting) + }) + .await; + + this.update(cx, |view, cx| { + view.engine.set_d2d_mapping(mapping); + let visible_rows = view.engine.d2d_mapping().visible_row_count(); + // Uses the row height measured on the last render. Cheaper than a full + // `.measure_all()` pass; exact row heights are re-measured on scrolling. + view.list_state + .reset_with_uniform_height(visible_rows, view.row_height); + cx.notify(); + }) + .ok(); + })); } pub fn resolve_active_item_as_csv_editor( @@ -222,7 +273,7 @@ impl CsvPreviewView { Self::is_csv_file(&editor, cx).then_some(editor) } - fn is_csv_file(editor: &Entity, cx: &App) -> bool { + pub fn is_csv_file(editor: &Entity, cx: &App) -> bool { editor .read(cx) .buffer() @@ -301,7 +352,7 @@ impl PerformanceMetrics { .map(|(name, (duration, time))| { let took = duration.as_secs_f32() * 1000.; let ago = time.elapsed().as_secs(); - format!("{name}: {took:.2}ms {ago}s ago") + format!("{name}: {took:.3}ms {ago}s ago") }) .collect::>() .join("\n") diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index efa3573d7aa53d..116c8912a38684 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::{ CsvPreviewView, types::TableLikeContent, @@ -23,6 +25,7 @@ impl CsvPreviewView { cx: &mut Context, ) { let editor = self.active_editor_state.editor.clone(); + self.is_parsing = true; self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx)); } @@ -80,11 +83,13 @@ impl CsvPreviewView { .insert("Parsing", (parse_duration, Instant::now())); log::debug!("Parsed {} rows", parsed_csv.rows.len()); - view.engine.contents = parsed_csv; + view.engine.contents = Arc::new(parsed_csv); + view.engine.calculate_available_filters(); view.sync_column_widths(cx); view.last_parse_end_time = Some(parse_end_time); - view.apply_filter_sort(); + view.is_parsing = false; + view.apply_filter_sort(cx); cx.notify(); }) }) diff --git a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs index 3d0cf50cf1d34f..d9e7ce6ad9b61b 100644 --- a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs +++ b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs @@ -20,7 +20,7 @@ impl CsvPreviewView { let children = div() .absolute() - .top_24() + .bottom_8() .right_4() .px_3() .py_2() diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs index 90500d53d06917..f46014294324bc 100644 --- a/crates/csv_preview/src/renderer/preview_view.rs +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -1,22 +1,30 @@ use std::time::Instant; -use ui::{div, prelude::*}; +use ui::{SpinnerLabel, div, prelude::*}; use crate::CsvPreviewView; impl Render for CsvPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); - + let row_height = window.pixel_snap(window.line_height()); + if row_height != self.row_height { + self.row_height = row_height; + // Font size (rem size, buffer font override, ...) changed since the list was last + // measured: existing rows and unmeasured-item height hints are now the wrong size. + // Unlike `reset_with_uniform_height`, this preserves scroll position and keeps each + // item's prior size as a hint rather than dropping straight to a fresh guess. + self.list_state.remeasure(); + } let render_prep_start = Instant::now(); let table_with_settings = v_flex() .size_full() - .p_4() .bg(theme.colors().editor_background) .track_focus(&self.focus_handle) .child(self.render_settings_panel(window, cx)) .child({ - if self.engine.contents.number_of_cols == 0 { + let is_parsing = self.is_parsing; + if is_parsing || self.engine.contents.number_of_cols == 0 { div() .flex() .items_center() @@ -25,7 +33,15 @@ impl Render for CsvPreviewView { .text_ui(cx) .font_buffer(cx) .text_color(cx.theme().colors().text_muted) - .child("No CSV content to display") + .when(is_parsing, |div| { + div.child( + h_flex() + .gap_2() + .child(SpinnerLabel::new()) + .child("Loading…"), + ) + }) + .when(!is_parsing, |div| div.child("No CSV content to display")) .into_any_element() } else { self.create_table(&self.column_widths.widths, cx) diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs index 3a7e1b3a04664d..9a38a3fedd36ae 100644 --- a/crates/csv_preview/src/renderer/render_table.rs +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -51,7 +51,6 @@ impl CsvPreviewView { Table::new(cols) .interactable(&self.table_interaction_state) - .striped() .width_config(ColumnWidthConfig::Resizable(current_widths.clone())) .header(headers) .disable_base_style() @@ -70,6 +69,7 @@ impl CsvPreviewView { cols, display_row, row_identifier_text_color, + this.row_height, cx, ) .unwrap_or_else(|| panic!("Expected to render a table row")) @@ -84,6 +84,7 @@ impl CsvPreviewView { .rendered_indices .extend(range.clone()); + let row_height = this.row_height; range .filter_map(|display_index| { Self::render_single_table_row( @@ -91,6 +92,7 @@ impl CsvPreviewView { cols, DisplayRow(display_index), row_identifier_text_color, + row_height, cx, ) }) @@ -111,6 +113,7 @@ impl CsvPreviewView { cols: usize, display_row: DisplayRow, row_identifier_text_color: gpui::Hsla, + row_height: Pixels, cx: &Context, ) -> Option> { // Get the actual row index from our sorted indices @@ -129,14 +132,23 @@ impl CsvPreviewView { let display_cell_id = DisplayCellId::new(display_row, col); - let cell = div().size_full().whitespace_nowrap().text_ellipsis().child( - CsvPreviewView::create_selectable_cell( + let cell = div() + .size_full() + .when( + !this.settings.multiline_cells_effectively_enabled(), + |div| { + div.whitespace_nowrap() + .text_ellipsis() + .h(row_height) + .overflow_hidden() + }, + ) + .child(CsvPreviewView::create_selectable_cell( display_cell_id, cell_content, this.settings.vertical_alignment, cx, - ), - ); + )); elements.push( div() @@ -155,7 +167,6 @@ impl CsvPreviewView { }, )) }) - .text_ui(cx) .child(cell) .into_any_element(), ); diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs index 06a26e4696e471..b997f645769b95 100644 --- a/crates/csv_preview/src/renderer/row_identifiers.rs +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -34,7 +34,7 @@ impl LineNumber { if start + 1 == end { format!("{start}\n{end}") } else { - format!("{start}\n...\n{end}") + format!("{start}\n-\n{end}") } } RowIdentDisplayMode::Horizontal => { @@ -78,11 +78,6 @@ impl CsvPreviewView { (max_line_number as f32).log10().floor() as usize + 1 }; - // if !self.settings.multiline_cells_enabled { - // // Uses horizontal line numbers layout like `123-456`. Needs twice the size - // digit_count *= 2; - // } - let char_width_px = 9.0; // TODO: get real width of the characters let base_width = (digit_count as f32) * char_width_px; let padding = 20.0; @@ -157,7 +152,7 @@ impl CsvPreviewView { .contents .line_numbers .get(*data_row)? - .display_string(if self.settings.multiline_cells_enabled { + .display_string(if self.settings.multiline_cells_effectively_enabled() { RowIdentDisplayMode::Vertical } else { RowIdentDisplayMode::Horizontal @@ -169,14 +164,14 @@ impl CsvPreviewView { let value = div() .flex() .px_1() - .border_b_1() .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) .h_full() - .text_ui(cx) - // Row identifiers are always centered + .text_color(cx.theme().colors().text_muted) + .justify_center() .items_center() - .justify_end() .font_buffer(cx) + .text_ui(cx) .child(row_identifier) .into_any_element(); Some(value) diff --git a/crates/csv_preview/src/renderer/settings.rs b/crates/csv_preview/src/renderer/settings.rs index cafa2a4c1bd954..fe8f671f92c09f 100644 --- a/crates/csv_preview/src/renderer/settings.rs +++ b/crates/csv_preview/src/renderer/settings.rs @@ -1,9 +1,13 @@ use ui::{ - ActiveTheme as _, AnyElement, ButtonSize, Context, ContextMenu, DropdownMenu, ElementId, - IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex, + ActiveTheme as _, AnyElement, ButtonSize, Checkbox, Context, ContextMenu, DropdownMenu, + ElementId, IntoElement as _, ParentElement as _, Styled as _, ToggleState, Tooltip, Window, + div, h_flex, }; -use crate::{CsvPreviewView, settings::VerticalAlignment}; +use crate::{ + CsvPreviewView, + settings::{FilterSortOrder, VerticalAlignment}, +}; ///// Settings related ///// impl CsvPreviewView { @@ -18,6 +22,11 @@ impl CsvPreviewView { VerticalAlignment::Center => "Center", }; + let current_filter_sort_text = match self.settings.filter_sort_order { + FilterSortOrder::AlphaThenCount => "A-Z, then Count", + FilterSortOrder::CountThenAlpha => "Count, then A-Z", + }; + let view = cx.entity(); let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Top", None, { @@ -40,6 +49,27 @@ impl CsvPreviewView { }) }); + let filter_sort_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("A-Z, then Count", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::AlphaThenCount; + cx.notify(); + }); + } + }) + .entry("Count, then A-Z", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::CountThenAlpha; + cx.notify(); + }); + } + }) + }); + let panel = h_flex() .gap_4() .p_2() @@ -68,8 +98,54 @@ impl CsvPreviewView { "Choose vertical text alignment within cells", )), ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .text_sm() + .text_color(cx.theme().colors().text_muted) + .child("Filter Sort:"), + ) + .child( + DropdownMenu::new( + ElementId::Name("filter-sort-order-dropdown".into()), + current_filter_sort_text, + filter_sort_dropdown_menu, + ) + .trigger_size(ButtonSize::Compact) + .trigger_tooltip(Tooltip::text( + "Choose how filter values are sorted in the filter menu", + )), + ), ); + let multiline_enabled = self.settings.multiline_cells_enabled; + let panel = panel.child({ + let view = view.clone(); + Checkbox::new( + ElementId::Name("multiline-rows-checkbox".into()), + if multiline_enabled { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Display multiline rows") + .tooltip(Tooltip::text( + "When enabled, row height grows to show all content. \ + When disabled, only the first line is visible — hover a cell to see the rest.", + )) + .on_click(move |_state, _window, cx| { + view.update(cx, |this, cx| { + this.settings.multiline_cells_enabled = !this.settings.multiline_cells_enabled; + cx.notify(); + }); + }) + }); + #[cfg(feature = "dev-tools")] let panel = panel.child( h_flex() @@ -120,7 +196,6 @@ fn create_dev_only_popover_menu( view_entity.update(cx, |view, cx| { view.settings.rendering_with = RowRenderMechanism::VariableList; - view.settings.multiline_cells_enabled = true; cx.notify(); }) } @@ -137,7 +212,6 @@ fn create_dev_only_popover_menu( view_entity.update(cx, |view, cx| { view.settings.rendering_with = RowRenderMechanism::UniformList; - view.settings.multiline_cells_enabled = false; cx.notify(); }) } diff --git a/crates/csv_preview/src/renderer/table_cell.rs b/crates/csv_preview/src/renderer/table_cell.rs index 8100731e13adb9..e792bcac68d44d 100644 --- a/crates/csv_preview/src/renderer/table_cell.rs +++ b/crates/csv_preview/src/renderer/table_cell.rs @@ -27,28 +27,19 @@ fn create_table_cell( cx: &Context<'_, CsvPreviewView>, ) -> gpui::Stateful
{ div() - .id(ElementId::Name( - format!( - "csv-display-cell-{}-{}", - *display_cell_id.row, *display_cell_id.col - ) - .into(), + .id(ElementId::NamedInteger( + format!("csv-display-cell-{}", *display_cell_id.row).into(), + *display_cell_id.col as u64, )) .cursor_pointer() .flex() .h_full() .px_1() - .bg(cx.theme().colors().editor_background) - .border_b_1() .border_color(cx.theme().colors().border_variant) .map(|div| match vertical_alignment { VerticalAlignment::Top => div.items_start(), VerticalAlignment::Center => div.items_center(), }) - .map(|div| match vertical_alignment { - VerticalAlignment::Top => div.content_start(), - VerticalAlignment::Center => div.content_center(), - }) .font_buffer(cx) .tooltip(Tooltip::text(cell_content.clone())) .child(div().child(cell_content)) diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs index 05652b49a48ca9..444ee09099338e 100644 --- a/crates/csv_preview/src/renderer/table_header.rs +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -1,9 +1,15 @@ use gpui::ElementId; -use ui::{Tooltip, prelude::*}; +use ui::{ + ContextMenu, GradientFade, IconButton, IconName, IconSize, PopoverMenu, Tooltip, prelude::*, +}; use crate::{ CsvPreviewView, - table_data_engine::sorting_by_column::{AppliedSorting, SortDirection}, + settings::FilterSortOrder, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterEntryState}, + sorting_by_column::{AppliedSorting, SortDirection}, + }, types::AnyColumn, }; @@ -15,14 +21,60 @@ impl CsvPreviewView { cx: &mut Context<'_, CsvPreviewView>, col_idx: AnyColumn, ) -> AnyElement { - // CSV data columns: text + filter/sort buttons + let has_active_filter = self.engine.has_active_filters(col_idx); + let has_active_sort = self + .engine + .applied_sorting + .is_some_and(|o| o.col_idx == col_idx); + let always_show_buttons = has_active_filter || has_active_sort; + let group_name = SharedString::from(format!("csv-col-header-{}", col_idx.get())); + + let colors = cx.theme().colors(); + let base_bg = colors.editor_background; + let grad_width_hovered = px(100.); + let grad_width = if always_show_buttons { + grad_width_hovered + } else { + px(20.) + }; h_flex() - .justify_between() - .items_center() + .group(group_name.clone()) + .relative() + .overflow_hidden() .w_full() + .items_center() .font_buffer(cx) - .child(div().child(header_text)) - .child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx))) + .text_buffer(cx) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .child(header_text), + ) + .child( + GradientFade::new(base_bg, base_bg, base_bg) + .width(grad_width) + .width_hovered(grad_width_hovered) + .right(px(0.)) + .gradient_stop(0.8) + .group_name(group_name.clone()), + ) + .child( + h_flex() + .absolute() + .right_0() + .top_0() + .h_full() + .items_center() + .gap_1() + .when(!always_show_buttons, |this| { + this.visible_on_hover(group_name) + }) + .child(self.create_filter_button(cx, col_idx)) + .child(self.create_sort_button(cx, col_idx)), + ) .into_any_element() } @@ -82,9 +134,192 @@ impl CsvPreviewView { }; this.engine.applied_sorting = new_sorting; - this.apply_sort(); + this.apply_sort(cx); cx.notify(); })); sort_btn } + + fn create_filter_button( + &self, + cx: &mut Context<'_, CsvPreviewView>, + col: AnyColumn, + ) -> PopoverMenu { + let has_active_filters = self.engine.has_active_filters(col); + + PopoverMenu::new(ElementId::NamedInteger( + "filter-menu".into(), + col.get() as u64, + )) + .trigger_with_tooltip( + IconButton::new( + ElementId::NamedInteger("filter-button".into(), col.get() as u64), + IconName::Filter, + ) + .icon_size(IconSize::Small) + .style(if has_active_filters { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }) + .toggle_state(has_active_filters), + Tooltip::text(if has_active_filters { + "Column has active filters. Click to manage" + } else { + "No filters applied. Click to add filters" + }), + ) + .menu({ + let view_entity = cx.entity(); + move |window, cx| { + let view = view_entity.read(cx); + let column_filters = match view.engine.get_filters_for_column(col) { + Ok(filters) => filters, + Err(err) => { + log::error!("Failed to get filters for column: {err}"); + return None; + } + }; + let filter_sort_order = view.settings.filter_sort_order; + let filter_menu = Self::create_filter_menu( + window, + cx, + view_entity.clone(), + col, + &column_filters, + has_active_filters, + filter_sort_order, + ); + Some(filter_menu) + } + }) + } + + fn create_filter_menu( + window: &mut ui::Window, + cx: &mut ui::App, + view_entity: gpui::Entity, + col: AnyColumn, + column_filters: &[(FilterEntry, FilterEntryState)], + has_active_filters: bool, + sort_order: FilterSortOrder, + ) -> gpui::Entity { + let mut available: Vec<&FilterEntry> = column_filters + .iter() + .filter_map(|(entry, state)| { + matches!(state, FilterEntryState::Available { .. }).then_some(entry) + }) + .collect(); + + match sort_order { + FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| { + a.content + .cmp(&b.content) + .then_with(|| b.occurred_times().cmp(&a.occurred_times())) + }), + FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| { + b.occurred_times() + .cmp(&a.occurred_times()) + .then_with(|| a.content.cmp(&b.content)) + }), + } + + let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Unavailable { blocked_by } = state { + Some((entry, *blocked_by)) + } else { + None + } + }) + .collect(); + + // Pre-build applied-state lookup before moving into the closure + let applied_states: Vec<(FilterEntry, bool)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Available { is_applied } = state { + Some((entry.clone(), *is_applied)) + } else { + None + } + }) + .collect(); + + let available_cloned: Vec = available.iter().map(|e| (*e).clone()).collect(); + let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable + .into_iter() + .map(|(e, col)| (e.clone(), col)) + .collect(); + + ContextMenu::build(window, cx, move |menu, _, _| { + let mut menu = menu; + + if has_active_filters { + menu = menu + .toggleable_entry("Clear all", false, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.clear_filters(col, cx); + cx.notify(); + }); + } + }) + .separator(); + } + + for entry in &available_cloned { + let is_applied = applied_states + .iter() + .find(|(e, _)| e.content == entry.content) + .map_or(false, |(_, applied)| *applied); + + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + let entry_value = entry.content.clone(); + + menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.toggle_filter(col, entry_value.clone(), cx); + cx.notify(); + }); + } + }); + } + + if !unavailable_cloned.is_empty() { + menu = menu.separator().header("Hidden by other filters"); + for (entry, _blocked_by) in &unavailable_cloned { + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + menu = menu.custom_entry( + { + let label = label.clone(); + move |_window, cx| { + div() + .px_2() + .text_color(cx.theme().colors().text_muted) + .child(label.clone()) + .into_any_element() + } + }, + |_, _| {}, + ); + } + } + + menu + }) + } +} + +fn format_filter_label(content: Option<&SharedString>, count: usize) -> String { + match content { + Some(s) => format!("{s} ({count})"), + None => format!(" ({count})"), + } } diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs index 215d681c28fd7f..dc871fa4b38789 100644 --- a/crates/csv_preview/src/settings.rs +++ b/crates/csv_preview/src/settings.rs @@ -1,10 +1,10 @@ #[derive(Default, Clone, Copy, PartialEq)] pub enum RowRenderMechanism { /// More correct for multiline content, but slower. - #[allow(dead_code)] // Will be used when settings ui is added + #[default] VariableList, /// Default behaviour for now while resizable columns are being stabilized. - #[default] + #[allow(dead_code)] // Will be used when settings ui is added UniformList, } @@ -26,13 +26,32 @@ pub enum RowIdentifiers { RowNum, } +#[derive(Default, Clone, Copy, PartialEq)] +pub enum FilterSortOrder { + /// Sort alphabetically (A→Z), then by number of occurrences descending within ties + #[default] + AlphaThenCount, + /// Sort by number of occurrences descending, then alphabetically within ties + CountThenAlpha, +} + #[derive(Clone, Default)] pub(crate) struct CsvPreviewSettings { pub(crate) rendering_with: RowRenderMechanism, pub(crate) vertical_alignment: VerticalAlignment, pub(crate) numbering_type: RowIdentifiers, + pub(crate) filter_sort_order: FilterSortOrder, pub(crate) show_debug_info: bool, #[cfg(feature = "dev-tools")] pub(crate) show_perf_metrics_overlay: bool, pub(crate) multiline_cells_enabled: bool, } + +impl CsvPreviewSettings { + /// `multiline_cells_enabled` only makes sense with `VariableList`, which + /// supports per-row heights; `UniformList` requires every row to share one + /// height, so multiline is never honored there regardless of the setting. + pub(crate) fn multiline_cells_effectively_enabled(&self) -> bool { + self.multiline_cells_enabled && self.rendering_with == RowRenderMechanism::VariableList + } +} diff --git a/crates/csv_preview/src/table_data_engine.rs b/crates/csv_preview/src/table_data_engine.rs index 382b41a2850721..f2613215fa5c04 100644 --- a/crates/csv_preview/src/table_data_engine.rs +++ b/crates/csv_preview/src/table_data_engine.rs @@ -5,22 +5,32 @@ //! //! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use ui::table_row::TableRow; use crate::{ - table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows}, - types::{DataRow, DisplayRow, TableCell, TableLikeContent}, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterStack, calculate_available_filters, retain_rows}, + sorting_by_column::{AppliedSorting, sort_data_rows}, + }, + types::{AnyColumn, DataRow, DisplayRow, TableCell, TableLikeContent}, }; +pub mod filtering_by_column; pub mod sorting_by_column; #[derive(Default)] pub(crate) struct TableDataEngine { + pub filter_stack: FilterStack, + /// Pre-computed unique values per column, used to populate filter menus + all_filters: HashMap>, pub applied_sorting: Option, d2d_mapping: DisplayToDataMapping, - pub contents: TableLikeContent, + pub contents: Arc, } impl TableDataEngine { @@ -28,32 +38,47 @@ impl TableDataEngine { &self.d2d_mapping } - pub(crate) fn apply_sort(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + pub(crate) fn set_d2d_mapping(&mut self, mapping: DisplayToDataMapping) { + self.d2d_mapping = mapping; } - /// Applies sorting and filtering to the data and produces display to data mapping - pub(crate) fn calculate_d2d_mapping(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + /// Recomputes the unique filter entries for every column from the current table data. + /// Must be called after content changes (e.g. after parsing). + pub fn calculate_available_filters(&mut self) { + self.all_filters = + calculate_available_filters(&self.contents.rows, self.contents.number_of_cols); } } /// Relation of Display (rendered) rows to Data (src) rows with applied transformations /// Transformations applied: /// - sorting by column +/// - filtering by column values #[derive(Debug, Default)] pub struct DisplayToDataMapping { - /// All rows sorted, regardless of applied filtering. Applied every time sorting changes + /// All rows sorted, regardless of applied filtering. Recomputed every time sorting changes pub sorted_rows: Vec, - /// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows` + /// Rows that survive the active filters. Recomputed every time filters change + pub retained_rows: HashSet, + /// Merged result: sorted rows intersected with retained rows pub mapping: Arc>, } impl DisplayToDataMapping { + /// Computes the full display-to-data mapping from owned inputs. + /// Intended to be called from a background thread. + pub(crate) fn compute( + contents: &Arc, + filter_stack: &FilterStack, + sorting: Option, + ) -> Self { + let mut mapping = Self::default(); + mapping.apply_sorting(sorting, &contents.rows); + mapping.apply_filtering(filter_stack, &contents.rows); + mapping.merge_mappings(); + mapping + } + /// Get the data row for a given display row pub fn get_data_row(&self, display_row: DisplayRow) -> Option { self.mapping.get(&display_row).copied() @@ -77,11 +102,16 @@ impl DisplayToDataMapping { self.sorted_rows = sorted_rows; } - /// Take pre-computed sorting and filtering results, and apply them to the mapping + fn apply_filtering(&mut self, filter_stack: &FilterStack, rows: &[TableRow]) { + self.retained_rows = retain_rows(rows, filter_stack); + } + + /// Merges pre-computed sorting and filtering into the final display mapping fn merge_mappings(&mut self) { self.mapping = Arc::new( self.sorted_rows .iter() + .filter(|data_row| self.retained_rows.contains(data_row)) .enumerate() .map(|(display, data)| (DisplayRow(display), *data)) .collect(), diff --git a/crates/csv_preview/src/table_data_engine/filtering_by_column.rs b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs new file mode 100644 index 00000000000000..b2b8a78c05aea8 --- /dev/null +++ b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs @@ -0,0 +1,250 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use ui::{SharedString, table_row::TableRow}; + +use crate::{ + table_data_engine::TableDataEngine, + types::{AnyColumn, DataRow, TableCell}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FilterEntryState { + Available { is_applied: bool }, + Unavailable { blocked_by: AnyColumn }, +} + +#[derive(Debug, Clone)] +pub struct FilterEntry { + /// Content to display. None if cell is virtual + pub content: Option, + /// List of rows in which this value occurs + pub rows: Vec, +} + +impl FilterEntry { + pub(crate) fn occurred_times(&self) -> usize { + self.rows.len() + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct FilterStack { + /// Columns in the order their first filter was applied, used to compute cascade availability + activation_order: Vec, + /// Which cell values are currently allowed for each filtered column + retention_config: HashMap>>, +} + +impl TableDataEngine { + pub(crate) fn has_active_filters(&self, col: AnyColumn) -> bool { + self.filter_stack.retention_config.contains_key(&col) + } + + /// Get available filters for a specific column with cascade behavior. + /// + /// A filter entry is "unavailable" when all of its rows are hidden by a + /// filter on an earlier-activated column, meaning selecting it would show + /// zero rows. The cascade walk stops at `column` so that the column's own + /// current filter does not affect its own entry availability. + pub(crate) fn get_filters_for_column( + &self, + column: AnyColumn, + ) -> anyhow::Result>> { + let all_column_entries = self + .all_filters + .get(&column) + .ok_or_else(|| anyhow::anyhow!("Expected {column:?} to have filter entries"))?; + + let mut unavailable_entries: HashMap, AnyColumn> = HashMap::new(); + + for &column_applied_previously in &self.filter_stack.activation_order { + if column_applied_previously == column { + break; + } + + let retained_values = self + .filter_stack + .retention_config + .get(&column_applied_previously) + .ok_or_else(|| { + anyhow::anyhow!( + "Expected {column_applied_previously:?} to have retained entries \ + as it is present in the filter stack" + ) + })?; + + // Rows that survive the filter on `column_applied_previously` + let retained_rows: HashSet = self + .contents + .rows + .iter() + .enumerate() + .filter(|(_, row)| { + let cell_value = row + .get(column_applied_previously) + .and_then(|cell| cell.display_value().cloned()); + retained_values.contains(&cell_value) + }) + .map(|(index, _)| DataRow(index)) + .collect(); + + // An entry is unavailable when none of its rows survive the parent filter + for entry in all_column_entries { + if !entry.rows.iter().any(|row| retained_rows.contains(row)) { + unavailable_entries.insert(entry.content.clone(), column_applied_previously); + } + } + } + + let empty = HashSet::new(); + let active_column_filters = self + .filter_stack + .retention_config + .get(&column) + .unwrap_or(&empty); + + Ok(Arc::new( + all_column_entries + .iter() + .map(|entry| { + let state = if let Some(&blocked_by) = unavailable_entries.get(&entry.content) { + FilterEntryState::Unavailable { blocked_by } + } else { + FilterEntryState::Available { + is_applied: active_column_filters.contains(&entry.content), + } + }; + (entry.clone(), state) + }) + .collect(), + )) + } + + pub(crate) fn clear_filters_for_col(&mut self, col: AnyColumn) { + self.filter_stack + .activation_order + .retain(|&entry| entry != col); + self.filter_stack.retention_config.remove(&col); + } + + /// Toggle a filter value for a column. Returns `true` if the filter was + /// added, `false` if it was removed. + pub(crate) fn toggle_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result { + let is_currently_applied = self + .filter_stack + .retention_config + .get(&column) + .is_some_and(|filters| filters.contains(&value)); + + if is_currently_applied { + self.remove_filter(column, value)?; + Ok(false) + } else { + self.apply_filter(column, value); + Ok(true) + } + } + + fn remove_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result<()> { + let entries = self + .filter_stack + .retention_config + .get_mut(&column) + .ok_or_else(|| { + anyhow::anyhow!("Expected {column:?} to be present in active filters") + })?; + + debug_assert!( + entries.contains(&value), + "Expected value to be present in {column:?} active filters" + ); + + if entries.len() == 1 { + self.filter_stack.retention_config.remove(&column); + self.filter_stack + .activation_order + .retain(|&entry| entry != column); + } else { + entries.remove(&value); + } + Ok(()) + } + + fn apply_filter(&mut self, column: AnyColumn, value: Option) { + // Track the column only on its first activation to preserve cascade order + if !self.filter_stack.activation_order.contains(&column) { + self.filter_stack.activation_order.push(column); + } + self.filter_stack + .retention_config + .entry(column) + .or_default() + .insert(value); + } +} + +/// Calculate available filter entries for each column from the table data. +pub fn calculate_available_filters( + content_rows: &[TableRow], + number_of_cols: usize, +) -> HashMap> { + let mut available_filters = HashMap::new(); + + for col_idx in 0..number_of_cols { + let column = AnyColumn::new(col_idx); + let mut value_to_rows: HashMap, Vec> = HashMap::new(); + + for (row_index, row) in content_rows.iter().enumerate() { + let cell_value = row + .get(column) + .and_then(|cell| cell.display_value().cloned()); + value_to_rows + .entry(cell_value) + .or_default() + .push(DataRow(row_index)); + } + + let filter_entries: Vec = value_to_rows + .into_iter() + .map(|(content, rows)| FilterEntry { content, rows }) + .collect(); + + available_filters.insert(column, filter_entries); + } + + available_filters +} + +/// Returns the set of data rows that survive all active filters in the stack. +pub fn retain_rows( + content_rows: &[TableRow], + filter_stack: &FilterStack, +) -> HashSet { + let config = &filter_stack.retention_config; + if config.is_empty() { + return (0..content_rows.len()).map(DataRow).collect(); + } + + content_rows + .iter() + .enumerate() + .filter(|(_, row)| { + config.iter().all(|(col, allowed_values)| { + let cell_value = row.get(*col).and_then(|cell| cell.display_value().cloned()); + allowed_values.contains(&cell_value) + }) + }) + .map(|(index, _)| DataRow(index)) + .collect() +} diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index 195d0d8df904b4..0a7857152b425c 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -68,8 +68,6 @@ 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 ui_input.workspace = true unindent = { workspace = true, optional = true } diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index e09fcca628a4d3..e0628a0cf4e4b7 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -14,7 +14,7 @@ use collections::IndexMap; use dap::adapters::DebugAdapterName; use dap::{DapRegistry, StartDebuggingRequestArguments}; use dap::{client::SessionId, debugger_settings::DebuggerSettings}; -use editor::{Editor, MultiBufferOffset, ToPoint}; +use editor::Editor; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use gpui::{ Action, Anchor, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, @@ -29,11 +29,12 @@ use project::{DebugScenarioContext, Fs, ProjectPath, TaskSourceKind, WorktreeId} use project::{Project, debugger::session::ThreadStatus}; use rpc::proto::{self}; use settings::Settings; -use std::sync::{Arc, LazyLock}; +use std::sync::Arc; use task::{DebugScenario, SharedTaskContext}; -use tree_sitter::{Query, StreamingIterator as _}; + use ui::{ - ContextMenu, Divider, PopoverMenu, PopoverMenuHandle, SplitButton, Tab, Tooltip, prelude::*, + ButtonLike, ContextMenu, Divider, ElevationIndex, PopoverMenu, PopoverMenuHandle, SplitButton, + Tab, TintColor, Tooltip, prelude::*, }; use util::redact::redact_command; use util::rel_path::RelPath; @@ -1001,28 +1002,26 @@ impl DebugPanel { .map(|session| session.read(cx).running_state()) .cloned(), |this, running_state| { - this.children({ - let threads = - running_state.update(cx, |running_state, cx| { - let session = running_state.session(); - session.read(cx).is_started().then(|| { - session.update(cx, |session, cx| { - session.threads(cx) - }) - }) - }); - - threads.and_then(|threads| { - self.render_thread_dropdown( - &running_state, - threads, - window, - cx, - ) + let threads = running_state.update(cx, |running_state, cx| { + let session = running_state.session(); + session.read(cx).is_started().then(|| { + session.update(cx, |session, cx| session.threads(cx)) + }) + }); + + let thread_dropdown = threads.and_then(|threads| { + self.render_thread_dropdown( + &running_state, + threads, + window, + cx, + ) + }); + + this.when_some(thread_dropdown, |this, dropdown| { + this.child(dropdown).when(!is_side, |this| { + this.gap_0p5().child(Divider::vertical()) }) - }) - .when(!is_side, |this| { - this.gap_0p5().child(Divider::vertical()) }) }, ), @@ -1138,14 +1137,14 @@ impl DebugPanel { directory_in_worktree: dir, .. } => { - let relative_path = if dir.ends_with(RelPath::unix(".vscode").unwrap()) { - dir.join(RelPath::unix("launch.json").unwrap()) + let relative_path = if dir.ends_with(RelPath::from_unix_str(".vscode").unwrap()) { + dir.join(RelPath::from_unix_str("launch.json").unwrap()) } else { - dir.join(RelPath::unix("debug.json").unwrap()) + dir.join(RelPath::from_unix_str("debug.json").unwrap()) }; ProjectPath { worktree_id: id, - path: relative_path, + path: relative_path.into(), } } _ => return self.save_scenario(scenario, worktree_id, window, cx), @@ -1268,76 +1267,7 @@ impl DebugPanel { window: &mut Window, cx: &mut Context, ) -> Result>> { - static LAST_ITEM_QUERY: LazyLock = LazyLock::new(|| { - Query::new( - &tree_sitter_json::LANGUAGE.into(), - "(document (array (object) @object))", // TODO: use "." anchor to only match last object - ) - .expect("Failed to create LAST_ITEM_QUERY") - }); - static EMPTY_ARRAY_QUERY: LazyLock = LazyLock::new(|| { - Query::new( - &tree_sitter_json::LANGUAGE.into(), - "(document (array) @array)", - ) - .expect("Failed to create EMPTY_ARRAY_QUERY") - }); - - let content = editor.text(cx); - let mut parser = tree_sitter::Parser::new(); - parser.set_language(&tree_sitter_json::LANGUAGE.into())?; - let mut cursor = tree_sitter::QueryCursor::new(); - let syntax_tree = parser - .parse(&content, None) - .context("could not parse debug.json")?; - let mut matches = cursor.matches( - &LAST_ITEM_QUERY, - syntax_tree.root_node(), - content.as_bytes(), - ); - - let mut last_offset = None; - while let Some(mat) = matches.next() { - if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end) { - last_offset = Some(MultiBufferOffset(pos)) - } - } - let mut edits = Vec::new(); - let mut cursor_position = MultiBufferOffset(0); - - if let Some(pos) = last_offset { - edits.push((pos..pos, format!(",\n{new_scenario}"))); - cursor_position = pos + ",\n ".len(); - } else { - let mut matches = cursor.matches( - &EMPTY_ARRAY_QUERY, - syntax_tree.root_node(), - content.as_bytes(), - ); - - if let Some(mat) = matches.next() { - if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end - 1) { - edits.push(( - MultiBufferOffset(pos)..MultiBufferOffset(pos), - format!("\n{new_scenario}\n"), - )); - cursor_position = MultiBufferOffset(pos) + "\n ".len(); - } - } else { - edits.push(( - MultiBufferOffset(0)..MultiBufferOffset(0), - format!("[\n{}\n]", new_scenario), - )); - cursor_position = MultiBufferOffset("[\n ".len()); - } - } - editor.transact(window, cx, |editor, window, cx| { - editor.edit(edits, cx); - let snapshot = editor.buffer().read(cx).read(cx); - let point = cursor_position.to_point(&snapshot); - drop(snapshot); - editor.go_to_singleton_buffer_point(point, window, cx); - }); + tasks_ui::insert_task_json_into_editor(editor, new_scenario, window, cx)?; Ok(editor.save(SaveOptions::default(), project, window, cx)) } @@ -1398,9 +1328,14 @@ impl DebugPanel { running_state: &Entity, thread_status: ThreadStatus, window: &mut Window, - ) -> IconButton { - IconButton::new("debug-back-in-history", IconName::HistoryRerun) - .icon_size(IconSize::Small) + ) -> ButtonLike { + ButtonLike::new_rounded_left("debug-back-in-history") + .layer(ElevationIndex::ModalSurface) + .child(Icon::new(IconName::HistoryRerun).size(IconSize::Small)) + .disabled( + thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, + ) + .tooltip(Tooltip::text("Step Back in Session History")) .on_click(window.listener_for(running_state, |this, _, _window, cx| { this.session().update(cx, |session, cx| { let ix = session @@ -1410,9 +1345,6 @@ impl DebugPanel { session.select_historic_snapshot(Some(ix.saturating_sub(1)), cx); }) })) - .disabled( - thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, - ) } fn render_history_toggle_button( @@ -1420,20 +1352,19 @@ impl DebugPanel { thread_status: ThreadStatus, running_state: &Entity, ) -> impl IntoElement { + let chevron_button_size = rems_from_px(20.); PopoverMenu::new("debug-back-in-history-menu") .trigger( - ui::ButtonLike::new_rounded_right("debug-back-in-history-menu-trigger") - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::None) - .child( - div() - .px_1() - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ) + ButtonLike::new_rounded_right("debug-back-in-history-menu-trigger") + .layer(ElevationIndex::ModalSurface) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .disabled( thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, - ), + ) + .width(chevron_button_size) + .height(chevron_button_size.into()) + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), ) .menu({ let running_state = running_state.clone(); @@ -1464,7 +1395,10 @@ impl DebugPanel { handler(None, running_state.clone(), cx); } }); - context_menu = context_menu.separator(); + + if !history.is_empty() { + context_menu = context_menu.separator(); + } for (ix, _) in history.iter().enumerate().rev() { context_menu = @@ -2000,7 +1934,7 @@ impl Render for DebugPanel { h_flex() .size_full() .child(breakpoint_list) - .child(Divider::vertical()) + .child(Divider::vertical().h_full()) .child(dashboard), ), ) diff --git a/crates/debugger_ui/src/dropdown_menus.rs b/crates/debugger_ui/src/dropdown_menus.rs index 0e07cb8841b08c..1fb7f901540438 100644 --- a/crates/debugger_ui/src/dropdown_menus.rs +++ b/crates/debugger_ui/src/dropdown_menus.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use collections::HashMap; use gpui::{Anchor, Entity, WeakEntity}; use project::debugger::session::{ThreadId, ThreadStatus}; -use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*}; +use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, Indicator, Tooltip, prelude::*}; use util::{maybe, truncate_and_trailoff}; use crate::{ @@ -132,7 +132,7 @@ impl DebugPanel { let session_state_indicator = if is_terminated { Indicator::dot().color(Color::Error).into_any_element() } else if !is_started { - Icon::new(IconName::ArrowCircle) + Icon::new(IconName::LoadCircle) .size(IconSize::Small) .color(Color::Muted) .with_rotate_animation(2) @@ -147,7 +147,6 @@ impl DebugPanel { let trigger = h_flex() .gap_2() .child(session_state_indicator) - .justify_between() .child( DebugPanel::dropdown_label(trigger_label) .when(is_terminated, |this| this.strikethrough()), @@ -212,8 +211,8 @@ impl DebugPanel { }), ) .attach(Anchor::BottomLeft) - .style(DropdownStyle::Ghost) - .handle(self.session_picker_menu_handle.clone()); + .handle(self.session_picker_menu_handle.clone()) + .trigger_tooltip(Tooltip::text("Select a Debug Session")); Some(menu) } @@ -325,7 +324,6 @@ impl DebugPanel { ) .attach(Anchor::BottomLeft) .disabled(session_terminated) - .style(DropdownStyle::Ghost) .handle(self.thread_picker_menu_handle.clone()), ) } else { diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index ab46fe7ee5f1d2..3464779988c36a 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1072,10 +1072,10 @@ impl DebugDelegate { match path.components().next_back() { Some(".zed") => { - path.push(RelPath::unix("debug.json").unwrap()); + path.push(RelPath::from_unix_str("debug.json").unwrap()); } Some(".vscode") => { - path.push(RelPath::unix("launch.json").unwrap()); + path.push(RelPath::from_unix_str("launch.json").unwrap()); } _ => {} } @@ -1168,7 +1168,7 @@ impl DebugDelegate { id: _, directory_in_worktree: dir, id_base: _, - } => dir.ends_with(RelPath::unix(".zed").unwrap()), + } => dir.ends_with(RelPath::from_unix_str(".zed").unwrap()), _ => false, }); @@ -1189,7 +1189,8 @@ impl DebugDelegate { id_base: _, } => { !(hide_vscode - && dir.ends_with(RelPath::unix(".vscode").unwrap())) + && dir + .ends_with(RelPath::from_unix_str(".vscode").unwrap())) } _ => true, }) diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs index 7282e5160d709a..6e68dfe5ba8647 100644 --- a/crates/debugger_ui/src/persistence.rs +++ b/crates/debugger_ui/src/persistence.rs @@ -70,23 +70,23 @@ impl DebuggerPaneItem { pub(crate) fn tab_tooltip(self) -> SharedString { let tooltip = match self { DebuggerPaneItem::Console => { - "Displays program output and allows manual input of debugger commands." + "Displays program output and allows manual input of debugger commands" } DebuggerPaneItem::Variables => { - "Shows current values of local and global variables in the current stack frame." + "Shows current values of local and global variables in the current stack frame" } - DebuggerPaneItem::BreakpointList => "Lists all active breakpoints set in the code.", + DebuggerPaneItem::BreakpointList => "Lists all active breakpoints set in the code", DebuggerPaneItem::Frames => { - "Displays the call stack, letting you navigate between function calls." + "Displays the call stack, letting you navigate between function calls" } - DebuggerPaneItem::Modules => "Shows all modules or libraries loaded by the program.", + DebuggerPaneItem::Modules => "Shows all modules or libraries loaded by the program", DebuggerPaneItem::LoadedSources => { - "Lists all source files currently loaded and used by the debugger." + "Lists all source files currently loaded and used by the debugger" } DebuggerPaneItem::Terminal => { - "Provides an interactive terminal session within the debugging environment." + "Provides an interactive terminal session within the debugging environment" } - DebuggerPaneItem::MemoryView => "Allows inspection of memory contents.", + DebuggerPaneItem::MemoryView => "Allows inspection of memory contents", }; SharedString::new_static(tooltip) } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index c597c61ff0541f..081166a9ec9237 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -115,6 +115,7 @@ impl Render for RunningState { } else if let Some(active) = active { self.panes .render( + None, None, &ActivePaneDecorator::new(active, &self.workspace), window, @@ -382,20 +383,232 @@ impl Render for SubView { "subview-container-{}", self.kind.to_shared_string() )) - .on_hover(cx.listener(|this, hovered, _, cx| { - this.hovered = *hovered; - cx.notify(); - })) .size_full() - // Add border unconditionally to prevent layout shifts on focus changes. .border_1() .when(self.item_focus_handle.contains_focused(window, cx), |el| { el.border_color(cx.theme().colors().pane_focused_border) }) .child(self.inner.clone()) + .on_hover(cx.listener(|this, hovered, _, cx| { + this.hovered = *hovered; + cx.notify(); + })) + } +} + +struct DraggedTabPreview { + label: SharedString, +} + +impl Render for DraggedTabPreview { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = theme_settings::ThemeSettings::get_global(cx) + .ui_font + .clone(); + let colors = cx.theme().colors(); + + h_flex() + .font(ui_font) + .h_6() + .px_1() + .rounded_sm() + .shadow_md() + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .child(Label::new(self.label.clone()).size(LabelSize::Small)) } } +fn render_debugger_tab( + ix: usize, + item: &dyn ItemHandle, + selected: bool, + deemphasized: bool, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement + use<> { + let item_ = item.boxed_clone(); + let colors = cx.theme().colors(); + + div() + .border_l_2() + .border_color(gpui::transparent_black()) + .drag_over::(|wrapper, _, _, cx| wrapper.border_color(cx.theme().colors().text)) + .child( + div() + .cursor_pointer() + .id(format!("debugger_tab_{}", item.item_id().as_u64())) + .p_1() + .rounded_sm() + .map(|s| { + if selected { + s.bg(colors.text_accent.opacity(0.08)) + .hover(|s| s.bg(colors.text_accent.opacity(0.25))) + .text_color(colors.text_accent) + } else { + s.hover(|s| s.bg(colors.element_hover)) + } + }) + .when(deemphasized, |s| s.opacity(0.8)) + .child(item.tab_content( + TabContentParams { + selected, + deemphasized, + ..Default::default() + }, + window, + cx, + )) + .when_some(item.tab_tooltip_text(cx), |this, tooltip| { + this.tooltip(Tooltip::text(tooltip)) + }) + .on_click(cx.listener(move |this, _, window, cx| { + let index = this.index_for_item(&*item_); + if let Some(index) = index { + this.activate_item(index, true, true, window, cx); + } + })) + .on_drop( + cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { + if dragged_tab.item.downcast::().is_none() { + return; + } + this.drag_split_direction = None; + this.handle_tab_drop(dragged_tab, ix, false, window, cx) + }), + ) + .on_drag( + DraggedTab { + item: item.boxed_clone(), + pane: cx.entity(), + detail: 0, + is_active: selected, + ix, + }, + |tab, _, _, cx| { + let label = tab.item.tab_content_text(0, cx); + cx.new(|_| DraggedTabPreview { label }) + }, + ), + ) +} + +fn render_debugger_tab_bar( + pane: &mut Pane, + focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, +) -> gpui::AnyElement { + let active_pane_item = pane.active_item(); + let pane_group_id: SharedString = format!("pane-zoom-button-hover-{}", cx.entity_id()).into(); + let as_subview = active_pane_item + .as_ref() + .and_then(|item| item.downcast::()); + + let is_hovered = as_subview + .as_ref() + .is_some_and(|item| item.read(cx).hovered); + let deemphasized = !pane.has_focus(window, cx); + + let tabs = pane + .items() + .enumerate() + .map(|(ix, item)| { + let selected = active_pane_item + .as_ref() + .is_some_and(|active| active.item_id() == item.item_id()); + render_debugger_tab(ix, item.as_ref(), selected, deemphasized, window, cx) + }) + .collect::>(); + + h_flex() + .track_focus(focus_handle) + .group(pane_group_id.clone()) + .on_action(|_: &menu::Cancel, window, cx| { + if cx.stop_active_drag(window) { + } else { + cx.propagate(); + } + }) + .pl_1p5() + .pr_1() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().tab_bar_background) + .child( + h_flex() + .w_full() + .gap_1() + .h(Tab::container_height(cx)) + .children(tabs) + .on_drop( + cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { + if dragged_tab.item.downcast::().is_none() { + return; + } + this.drag_split_direction = None; + this.handle_tab_drop(dragged_tab, this.items_len(), false, window, cx) + }), + ) + .child( + div() + .flex_1() + .h_6() + .border_l_2() + .border_color(gpui::transparent_black()) + .drag_over::(|spacer, _, _, cx| { + spacer.border_color(cx.theme().colors().text) + }), + ), + ) + .child({ + let zoomed = pane.is_zoomed(); + + h_flex() + .visible_on_hover(pane_group_id) + .when(is_hovered, |this| this.visible()) + .when_some(as_subview.as_ref(), |this, subview| { + subview.update(cx, |view, cx| { + let Some(additional_actions) = view.actions.as_mut() else { + return this; + }; + this.child(additional_actions(window, cx)) + }) + }) + .child( + IconButton::new( + format!("debug-toggle-zoom-{}", cx.entity_id()), + if zoomed { + IconName::Minimize + } else { + IconName::Maximize + }, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |pane, _, _, cx| { + let is_zoomed = pane.is_zoomed(); + pane.set_zoomed(!is_zoomed, cx); + cx.notify(); + })) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + let zoomed_text = if zoomed { "Minimize" } else { "Expand" }; + Tooltip::for_action_in( + zoomed_text, + &ToggleExpandItem, + &focus_handle, + cx, + ) + } + }), + ) + }) + .into_any_element() +} + pub(crate) fn new_debugger_pane( workspace: WeakEntity, project: Entity, @@ -456,169 +669,7 @@ pub(crate) fn new_debugger_pane( pane.set_should_display_tab_bar(|_, _| true); pane.set_render_tab_bar_buttons(cx, |_, _, _| (None, None)); pane.set_render_tab_bar(cx, { - move |pane, window, cx| { - let active_pane_item = pane.active_item(); - let pane_group_id: SharedString = - format!("pane-zoom-button-hover-{}", cx.entity_id()).into(); - let as_subview = active_pane_item - .as_ref() - .and_then(|item| item.downcast::()); - let is_hovered = as_subview - .as_ref() - .is_some_and(|item| item.read(cx).hovered); - - h_flex() - .track_focus(&focus_handle) - .group(pane_group_id.clone()) - .pl_1p5() - .pr_1() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().tab_bar_background) - .on_action(|_: &menu::Cancel, window, cx| { - if cx.stop_active_drag(window) { - } else { - cx.propagate(); - } - }) - .child( - h_flex() - .w_full() - .gap_1() - .h(Tab::container_height(cx)) - .drag_over::(|bar, _, _, cx| { - bar.bg(cx.theme().colors().drop_target_background) - }) - .on_drop(cx.listener( - move |this, dragged_tab: &DraggedTab, window, cx| { - if dragged_tab.item.downcast::().is_none() { - return; - } - this.drag_split_direction = None; - this.handle_tab_drop( - dragged_tab, - this.items_len(), - false, - window, - cx, - ) - }, - )) - .children(pane.items().enumerate().map(|(ix, item)| { - let selected = active_pane_item - .as_ref() - .is_some_and(|active| active.item_id() == item.item_id()); - let deemphasized = !pane.has_focus(window, cx); - let item_ = item.boxed_clone(); - div() - .id(format!("debugger_tab_{}", item.item_id().as_u64())) - .p_1() - .rounded_md() - .cursor_pointer() - .when_some(item.tab_tooltip_text(cx), |this, tooltip| { - this.tooltip(Tooltip::text(tooltip)) - }) - .map(|this| { - let theme = cx.theme(); - if selected { - let color = theme.colors().tab_active_background; - let color = if deemphasized { - color.opacity(0.5) - } else { - color - }; - this.bg(color) - } else { - let hover_color = theme.colors().element_hover; - this.hover(|style| style.bg(hover_color)) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - let index = this.index_for_item(&*item_); - if let Some(index) = index { - this.activate_item(index, true, true, window, cx); - } - })) - .child(item.tab_content( - TabContentParams { - selected, - deemphasized, - ..Default::default() - }, - window, - cx, - )) - .on_drop(cx.listener( - move |this, dragged_tab: &DraggedTab, window, cx| { - if dragged_tab.item.downcast::().is_none() { - return; - } - this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, ix, false, window, cx) - }, - )) - .on_drag( - DraggedTab { - item: item.boxed_clone(), - pane: cx.entity(), - detail: 0, - is_active: selected, - ix, - }, - |tab, _, _, cx| cx.new(|_| tab.clone()), - ) - })), - ) - .child({ - let zoomed = pane.is_zoomed(); - - h_flex() - .visible_on_hover(pane_group_id) - .when(is_hovered, |this| this.visible()) - .when_some(as_subview.as_ref(), |this, subview| { - subview.update(cx, |view, cx| { - let Some(additional_actions) = view.actions.as_mut() else { - return this; - }; - this.child(additional_actions(window, cx)) - }) - }) - .child( - IconButton::new( - SharedString::from(format!( - "debug-toggle-zoom-{}", - cx.entity_id() - )), - if zoomed { - IconName::Minimize - } else { - IconName::Maximize - }, - ) - .icon_size(IconSize::Small) - .on_click(cx.listener(move |pane, _, _, cx| { - let is_zoomed = pane.is_zoomed(); - pane.set_zoomed(!is_zoomed, cx); - cx.notify(); - })) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - let zoomed_text = - if zoomed { "Minimize" } else { "Expand" }; - Tooltip::for_action_in( - zoomed_text, - &ToggleExpandItem, - &focus_handle, - cx, - ) - } - }), - ) - }) - .into_any_element() - } + move |pane, window, cx| render_debugger_tab_bar(pane, &focus_handle, window, cx) }); pane }) diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index a344a92eadd826..90fd5964d00a6a 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -19,8 +19,8 @@ use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session: use settings::Settings; use theme_settings::ThemeSettings; use ui::{ - ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render, - ScrollableHandle, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, + ContextMenu, Divider, DropdownMenu, PopoverMenuHandle, ScrollableHandle, + StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, }; use workspace::Workspace; @@ -368,7 +368,9 @@ impl MemoryView { this }), ) + .style(ui::DropdownStyle::Outlined) .handle(self.width_picker_handle.clone()) + .attach(gpui::Anchor::BottomLeft) } fn page_down(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { @@ -846,19 +848,16 @@ fn render_single_memory_view_line( } impl Render for MemoryView { - fn render( - &mut self, - window: &mut ui::Window, - cx: &mut ui::Context, - ) -> impl ui::IntoElement { + fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { let (icon, tooltip_text) = if self.is_writing_memory { - (IconName::Pencil, "Edit memory at a selected address") + (IconName::Pencil, "Edit Memory at a Selected Address") } else { ( IconName::LocationEdit, - "Change address of currently viewed memory", + "Change Address of Currently Viewed Memory", ) }; + v_flex() .id("Memory-view") .on_action(cx.listener(Self::cancel)) @@ -873,31 +872,31 @@ impl Render for MemoryView { .child( h_flex() .w_full() - .mb_0p5() + .mb_1() .gap_1() .child( h_flex() + .px_1() + .h_6() .w_full() - .rounded_md() + .rounded_sm() + .gap_1() .border_1() - .gap_x_2() - .px_2() - .py_0p5() - .mb_0p5() - .bg(cx.theme().colors().editor_background) .when_else( self.query_editor .focus_handle(cx) .contains_focused(window, cx), |this| this.border_color(cx.theme().colors().border_focused), - |this| this.border_color(cx.theme().colors().border_transparent), + |this| this.border_color(cx.theme().colors().border_variant), ) + .bg(cx.theme().colors().editor_background) .child( div() .id("memory-view-editor-icon") .child(Icon::new(icon).size(ui::IconSize::XSmall)) .tooltip(Tooltip::text(tooltip_text)), ) + .child(Divider::vertical()) .child(self.render_query_bar(cx)), ) .child(self.render_width_picker(window, cx)), diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index 61c4e30a51f46e..19ca14cca10619 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -395,7 +395,10 @@ impl StackFrameList { let stack_frame_id = stack_frame.id; self.opened_stack_frame_id = Some(stack_frame_id); let Some(abs_path) = Self::abs_path_from_stack_frame(&stack_frame) else { - return Task::ready(Err(anyhow!("Project path not found"))); + return Task::ready(Err(anyhow!( + "no absolute source path in stack frame {stack_frame_id}, source: {:?}", + stack_frame.source + ))); }; let row = stack_frame.line.saturating_sub(1) as u32; cx.emit(StackFrameListEvent::SelectedStackFrameChanged( @@ -521,7 +524,7 @@ impl StackFrameList { .filter(|path| { // Since we do not know if we are debugging on the host or (a remote/WSL) target, // we need to check if either the path is absolute as Posix or Windows. - is_absolute(path, PathStyle::Posix) || is_absolute(path, PathStyle::Windows) + is_absolute(path, PathStyle::Unix) || is_absolute(path, PathStyle::Windows) }) .map(|path| Arc::::from(Path::new(path))) }) @@ -911,7 +914,7 @@ impl StackFrameList { .child( IconButton::new( "filter-by-visible-worktree-stack-frame-list", - IconName::ListFilter, + IconName::Filter, ) .tooltip(move |_window, cx| { Tooltip::for_action(tooltip_title, &ToggleUserFrames, cx) diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index cdb5b8122a39f8..56aed0bb1c7de8 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -298,9 +298,10 @@ impl VariableList { contains_local_scope = true; } - self.session.update(cx, |session, cx| { - !session.variables(scope.variables_reference, cx).is_empty() - }) + scope.expensive + || self.session.update(cx, |session, cx| { + !session.variables(scope.variables_reference, cx).is_empty() + }) }) .map(|scope| { ( @@ -347,12 +348,13 @@ impl VariableList { .or_insert(EntryState { depth: path.indices.len(), is_expanded: dap_kind.as_scope().is_some_and(|scope| { - (scopes_count == 1 && !contains_local_scope) - || scope - .presentation_hint - .as_ref() - .map(|hint| *hint == ScopePresentationHint::Locals) - .unwrap_or(scope.name.to_lowercase().starts_with("local")) + !scope.expensive + && ((scopes_count == 1 && !contains_local_scope) + || scope + .presentation_hint + .as_ref() + .map(|hint| *hint == ScopePresentationHint::Locals) + .unwrap_or(scope.name.to_lowercase().starts_with("local"))) }), parent_reference: container_reference, has_children: variables_reference != 0, diff --git a/crates/debugger_ui/src/tests/console.rs b/crates/debugger_ui/src/tests/console.rs index b3a8fb0b6b03e9..af2122046ec791 100644 --- a/crates/debugger_ui/src/tests/console.rs +++ b/crates/debugger_ui/src/tests/console.rs @@ -4,8 +4,8 @@ use crate::{ *, }; use dap::requests::StackTrace; -use editor::{DisplayPoint, display_map::DisplayRow}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use language::Point; use project::{FakeFs, Project}; use serde_json::json; use tests::{init_test, init_test_workspace}; @@ -326,32 +326,44 @@ async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut Test .as_str() ); + // The console editor is rendered inside a narrow pane, so display + // coordinates depend on soft-wrapping. Convert the highlight ranges to + // buffer coordinates to make the assertions layout-independent. let text_highlights = editor.update(cx, |editor, cx| { - let mut text_highlights = editor.all_text_highlights(window, cx).into_iter().flat_map(|(_, ranges)| ranges).collect::>(); + let highlights = editor.all_text_highlights(window, cx); + let snapshot = editor.snapshot(window, cx); + let mut text_highlights = highlights + .into_iter() + .flat_map(|(_, ranges)| ranges) + .map(|range| range.start.to_point(&snapshot)..range.end.to_point(&snapshot)) + .collect::>(); text_highlights.sort_by_key(|hl| hl.start); text_highlights }); pretty_assertions::assert_eq!( text_highlights, [ - DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 21), - DisplayPoint::new(DisplayRow(1), 21)..DisplayPoint::new(DisplayRow(2), 0), - DisplayPoint::new(DisplayRow(5), 1)..DisplayPoint::new(DisplayRow(5), 4), - DisplayPoint::new(DisplayRow(5), 4)..DisplayPoint::new(DisplayRow(6), 0), - DisplayPoint::new(DisplayRow(7), 1)..DisplayPoint::new(DisplayRow(7), 4), - DisplayPoint::new(DisplayRow(7), 4)..DisplayPoint::new(DisplayRow(8), 0), - DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(9), 0), + Point::new(1, 3)..Point::new(1, 21), + Point::new(1, 21)..Point::new(2, 0), + Point::new(5, 1)..Point::new(5, 4), + Point::new(5, 4)..Point::new(6, 0), + Point::new(7, 1)..Point::new(7, 4), + Point::new(7, 4)..Point::new(8, 0), + Point::new(8, 0)..Point::new(9, 0), ] ); let background_highlights = editor.update(cx, |editor, cx| { - editor.all_text_background_highlights(window, cx).into_iter().map(|(range, _)| range).collect::>() + let highlights = editor.all_text_background_highlights(window, cx); + let snapshot = editor.snapshot(window, cx); + highlights + .into_iter() + .map(|(range, _)| range.start.to_point(&snapshot)..range.end.to_point(&snapshot)) + .collect::>() }); pretty_assertions::assert_eq!( background_highlights, - [ - DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(9), 0), - ] + [Point::new(8, 0)..Point::new(9, 0)] ) }) .unwrap(); diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 90b36074ccc95d..dd79fba3bb67d3 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -1985,6 +1985,18 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( }) .unwrap(); + // The debug panel opens as an item in the active pane, so give it a + // dedicated pane first to keep panes A and B showing only editors. + let debug_panel_pane = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.split_pane(pane_b.clone(), SplitDirection::Down, window, cx) + }) + }) + .unwrap(); + + cx.run_until_parked(); + // Start a debug session and trigger a breakpoint stop on main.rs line 2 let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); let client = session.update(cx, |session, _| session.adapter_client().unwrap()); @@ -2048,6 +2060,19 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( cx.run_until_parked(); + // Sanity check: the debug panel landed in its dedicated pane. + workspace + .read_with(cx, |_multi, cx| { + assert!( + debug_panel_pane + .read(cx) + .items() + .any(|item| item.downcast::().is_some()), + "Debug panel should be hosted in its dedicated pane", + ); + }) + .unwrap(); + // After first breakpoint stop on main.rs: // Pane A should still have second.rs as its active item because // main.rs was only an inactive tab there. The debugger should have jumped diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 8e6c1259921b45..5b88637306e0a2 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -478,6 +478,257 @@ async fn test_fetch_variables_for_multiple_scopes( }); } +/// A scope marked `expensive: true` by the DAP adapter (e.g. a JavaScript "Global" +/// scope) must not have its variables fetched automatically, since resolving it can +/// hang indefinitely. It should render collapsed, and only be resolved once the user +/// explicitly expands it. +#[gpui::test] +async fn test_expensive_scope_is_not_eagerly_fetched( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const local = 1; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client.on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }); + + client.on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }); + + client.on_request::(move |_, _| Ok(())); + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client.on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }); + + let scopes = vec![ + Scope { + name: "Local".into(), + presentation_hint: Some(dap::ScopePresentationHint::Locals), + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + Scope { + name: "Global".into(), + presentation_hint: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + expensive: true, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + ]; + + client.on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }); + + let fetched_expensive_scope = Arc::new(AtomicBool::new(false)); + + client.on_request::({ + let fetched_expensive_scope = fetched_expensive_scope.clone(); + move |_, args| match args.variables_reference { + 2 => Ok(dap::VariablesResponse { + variables: vec![Variable { + name: "localVar".into(), + value: "1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }], + }), + 3 => { + fetched_expensive_scope.store(true, Ordering::SeqCst); + Ok(dap::VariablesResponse { + variables: vec![Variable { + name: "globalVar".into(), + value: "expensive".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }], + }) + } + id => unreachable!("unexpected variables reference {id}"), + } + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + let running = item.running_state().clone(); + + let variable_list = running.update(cx, |state, cx| { + // have to do this because the variable list pane should be shown/active + // for testing keyboard navigation + state.activate_item(DebuggerPaneItem::Variables, window, cx); + + state.variable_list().clone() + }); + variable_list.update(cx, |_, cx| cx.focus_self(window)); + running + }); + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + // The expensive "Global" scope is visible but collapsed; its variables + // must not have been fetched yet. + variable_list.assert_visual_entries(vec!["v Local", " > localVar", "> Global"]); + }); + }); + + assert!( + !fetched_expensive_scope.load(Ordering::SeqCst), + "Zed must not eagerly fetch variables for a scope marked `expensive: true`" + ); + + // Select and expand the Global scope: only now should its variables be resolved. + cx.dispatch_action(SelectFirst); + cx.dispatch_action(SelectFirst); + cx.run_until_parked(); + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Local", + " > localVar", + "v Global <=== selected", + " > globalVar", + ]); + }); + }); + + assert!( + fetched_expensive_scope.load(Ordering::SeqCst), + "Expanding the Global scope should fetch its variables" + ); +} + // tests that toggling a variable will fetch its children and shows it #[gpui::test] async fn test_keyboard_navigation(executor: BackgroundExecutor, cx: &mut TestAppContext) { diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index a5ba5edd6f85d8..395d50b6bd3dd4 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -172,13 +172,13 @@ pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec Vec { let mut configs = Vec::new(); - let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path"); + let devcontainer_dir_path = RelPath::from_unix_str(".devcontainer").expect("valid path"); if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) { if devcontainer_entry.is_dir() { log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory"); let devcontainer_json_path = - RelPath::unix(".devcontainer/devcontainer.json").expect("valid path"); + RelPath::from_unix_str(".devcontainer/devcontainer.json").expect("valid path"); for entry in snapshot.child_entries(devcontainer_dir_path) { log::debug!( "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}", @@ -199,7 +199,7 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str()); - if let Ok(rel_config_path) = RelPath::unix(&config_json_path) { + if let Ok(rel_config_path) = RelPath::from_unix_str(&config_json_path) { if snapshot.entry_for_path(rel_config_path).is_some() { log::debug!( "find_configs_in_snapshot: Found config in subfolder: {}", @@ -223,7 +223,7 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec // Always include `.devcontainer.json` so the user can pick it from the UI // even when `.devcontainer/devcontainer.json` also exists. - let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path"); + let root_config_path = RelPath::from_unix_str(".devcontainer.json").expect("valid path"); if snapshot .entry_for_path(root_config_path) .is_some_and(|entry| entry.is_file()) @@ -400,7 +400,7 @@ pub(crate) async fn apply_devcontainer_template( log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError })?; - let rel_path = RelPath::unix(relative_path) + let rel_path = RelPath::from_unix_str(relative_path) .map_err(|e| { log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index fb2e4f6cd6b31c..3392e01da59e00 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -36,6 +36,11 @@ enum ConfigStatus { VariableParsed(DevContainer), } +enum ComposeUpBehavior { + Resume, + Create, +} + #[derive(Debug, Clone, Eq, PartialEq, Default)] pub(crate) struct DockerComposeResources { files: Vec, @@ -1800,13 +1805,34 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true &self, resources: DockerComposeResources, ) -> Result { + self.start_docker_compose_services(&resources, ComposeUpBehavior::Create) + .await?; + + if let Some(docker_ps) = self.check_for_existing_container().await? { + log::debug!("Found newly created dev container"); + return self.docker_client.inspect(&docker_ps.id).await; + } + + log::error!("Could not find existing container after docker compose up"); + + Err(DevContainerError::DevContainerParseFailed) + } + + async fn start_docker_compose_services( + &self, + resources: &DockerComposeResources, + behavior: ComposeUpBehavior, + ) -> Result<(), DevContainerError> { let mut command = Command::new(self.docker_client.docker_cli()); let project_name = self.project_name().await?; command.args(&["compose", "--project-name", &project_name]); - for docker_compose_file in resources.files { + for docker_compose_file in &resources.files { command.args(&["-f", &docker_compose_file.display().to_string()]); } command.args(&["up", "-d"]); + if matches!(behavior, ComposeUpBehavior::Resume) { + command.arg("--no-recreate"); + } if let Some(run_services) = self.dev_container().run_services.as_ref() { command.args(run_services); } @@ -1828,14 +1854,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true )); } - if let Some(docker_ps) = self.check_for_existing_container().await? { - log::debug!("Found newly created dev container"); - return self.docker_client.inspect(&docker_ps.id).await; - } - - log::error!("Could not find existing container after docker compose up"); - - Err(DevContainerError::DevContainerParseFailed) + Ok(()) } async fn run_docker_image( @@ -2165,7 +2184,15 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true if !docker_inspect.is_running() { log::debug!("Container not running. Will attempt to start, and then proceed"); - self.docker_client.start_container(&docker_ps.id).await?; + + match self.dev_container().build_type() { + DevContainerBuildType::DockerCompose => { + let resources = self.docker_compose_manifest().await?; + self.start_docker_compose_services(&resources, ComposeUpBehavior::Resume) + .await? + } + _ => self.docker_client.start_container(&docker_ps.id).await?, + } } let remote_user = get_remote_user_from_config(&docker_inspect, self)?; @@ -2793,54 +2820,77 @@ chmod +x ./install.sh Ok(script) } +struct ParsedFromLine<'a> { + image: &'a str, + alias: Option<&'a str>, +} + +/// Parses a `FROM` instruction into its image and optional stage alias, +/// skipping flags like `--platform=...`. Returns `None` for non-`FROM` lines. +fn parse_from_line(line: &str) -> Option> { + let mut tokens = line.split_whitespace(); + if !tokens.next()?.eq_ignore_ascii_case("FROM") { + return None; + } + let image = tokens.find(|token| !token.starts_with("--"))?; + let alias = match (tokens.next(), tokens.next()) { + (Some(keyword), Some(alias)) if keyword.eq_ignore_ascii_case("as") => Some(alias), + _ => None, + }; + Some(ParsedFromLine { image, alias }) +} + fn dockerfile_inject_alias( dockerfile_content: &str, alias: &str, build_target: Option, ) -> String { - let from_lines: Vec<(usize, &str)> = dockerfile_content + let from_lines: Vec<(usize, ParsedFromLine)> = dockerfile_content .lines() .enumerate() - .filter(|(_, line)| line.starts_with("FROM")) + .filter_map(|(index, line)| parse_from_line(line).map(|parsed| (index, parsed))) .collect(); let target_entry = match &build_target { - Some(target) => from_lines.iter().rfind(|(_, line)| { - let parts: Vec<&str> = line.split_whitespace().collect(); - parts.len() >= 3 - && parts - .get(parts.len() - 2) - .map_or(false, |p| p.eq_ignore_ascii_case("as")) - && parts - .last() - .map_or(false, |p| p.eq_ignore_ascii_case(target)) + Some(target) => from_lines.iter().rfind(|(_, parsed)| { + parsed + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(target)) }), None => from_lines.last(), }; - let Some(&(line_idx, from_line)) = target_entry else { + let Some((line_idx, parsed)) = target_entry else { + match &build_target { + Some(target) => log::warn!( + "Build target stage {target:?} not found in Dockerfile; leaving it unmodified" + ), + None => log::warn!("No FROM instruction found in Dockerfile; leaving it unmodified"), + } return dockerfile_content.to_string(); }; - let parts: Vec<&str> = from_line.split_whitespace().collect(); - let has_alias = parts.len() >= 3 - && parts - .get(parts.len() - 2) - .map_or(false, |p| p.eq_ignore_ascii_case("as")); - - if has_alias { - let Some(existing_alias) = parts.last() else { - return dockerfile_content.to_string(); - }; + if let Some(existing_alias) = parsed.alias { format!("{dockerfile_content}\nFROM {existing_alias} AS {alias}") } else { let lines: Vec<&str> = dockerfile_content.lines().collect(); + // Appending ` AS {alias}` to a line ending in a `\` continuation would + // corrupt the instruction, so leave the Dockerfile unmodified. + if lines + .get(*line_idx) + .is_some_and(|line| line.trim_end().ends_with('\\')) + { + log::warn!( + "FROM instruction spans multiple lines via `\\` continuation; cannot inject stage alias, leaving Dockerfile unmodified" + ); + return dockerfile_content.to_string(); + } let mut result = String::new(); for (i, line) in lines.iter().enumerate() { if i > 0 { result.push('\n'); } - if i == line_idx { + if i == *line_idx { result.push_str(&format!("{line} AS {alias}")); } else { result.push_str(line); @@ -2854,29 +2904,36 @@ fn dockerfile_inject_alias( } fn image_from_dockerfile(dockerfile_contents: String, target: &Option) -> Option { - dockerfile_contents + let stages: Vec = dockerfile_contents .lines() - .filter(|line| line.starts_with("FROM")) - .rfind(|from_line| match &target { - Some(target) => { - let parts = from_line.split(' ').collect::>(); - if parts.len() >= 3 - && parts.get(parts.len() - 2).unwrap_or(&"").to_lowercase() == "as" - { - parts.last().unwrap_or(&"").to_lowercase() == target.to_lowercase() - } else { - false - } - } - None => true, - }) - .and_then(|from_line| { - from_line - .split(' ') - .collect::>() - .get(1) - .map(|s| s.to_string()) - }) + .filter_map(parse_from_line) + .collect(); + + let start_index = match target { + Some(target) => stages.iter().rposition(|stage| { + stage + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(target)) + })?, + None => stages.len().checked_sub(1)?, + }; + + // Follow alias chains (`FROM base AS development`) to a concrete image. + // Docker only resolves names to stages defined earlier in the file, so + // resolving strictly backwards is correct and cannot cycle. + let mut index = start_index; + loop { + let image = stages.get(index)?.image; + let previous_stage = stages.get(..index)?.iter().rposition(|stage| { + stage + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(image)) + }); + match previous_stage { + Some(previous_index) => index = previous_index, + None => return Some(image.to_string()), + } + } } fn get_remote_user_from_config( @@ -2945,6 +3002,7 @@ mod test { use fs::{FakeFs, Fs}; use gpui::{AppContext, TestAppContext}; use http_client::{AsyncBody, FakeHttpClient, HttpClient}; + use indoc::indoc; use project::{ ProjectEnvironment, worktree_store::{WorktreeIdCounter, WorktreeStore}, @@ -2961,8 +3019,9 @@ mod test { devcontainer_json::MountDefinition, devcontainer_manifest::{ ConfigStatus, DevContainerManifest, DockerBuildResources, DockerComposeResources, - DockerInspect, extract_feature_id, find_primary_service, get_remote_user_from_config, - image_from_dockerfile, is_local_feature_ref, resolve_compose_dockerfile, + DockerInspect, dockerfile_inject_alias, extract_feature_id, find_primary_service, + get_remote_user_from_config, image_from_dockerfile, is_local_feature_ref, + resolve_compose_dockerfile, }, docker::{ DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild, @@ -4938,6 +4997,75 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ ); } + #[gpui::test] + async fn test_resumes_only_requested_compose_services_without_recreating( + cx: &mut TestAppContext, + ) { + let devcontainer_contents = r#" + { + "dockerComposeFile": "docker-compose.yml", + "service": "devcontainer", + "runServices": ["devcontainer", "db"], + "workspaceFolder": "/workspaces/project", + "updateRemoteUserUID": false + } + "#; + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"), + indoc! {r#" + services: + app: + image: test_image:latest + devcontainer: + image: test_image:latest + db: + image: postgres:18.4 + "# } + .to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + assert!( + devcontainer_manifest + .check_for_existing_devcontainer() + .await + .unwrap() + .is_some() + ); + + let command = test_dependencies + .command_runner + .commands_by_program("docker") + .into_iter() + .find(|command| { + command.args.first().map(String::as_str) == Some("compose") + && command.args.iter().any(|argument| argument == "up") + }) + .expect("docker compose up command recorded"); + + assert!( + command.args.ends_with(&[ + "up".to_string(), + "-d".to_string(), + "--no-recreate".to_string(), + "devcontainer".to_string(), + "db".to_string(), + ]), + "compose resume should target requested services without recreating them, got: {:?}", + command.args + ); + } + #[cfg(not(target_os = "windows"))] #[gpui::test] async fn test_spawns_devcontainer_with_docker_compose_and_podman(cx: &mut TestAppContext) { @@ -5877,6 +6005,93 @@ FROM ${IMAGE} AS production assert_eq!(base_image, "docker.io/stuff/mybuild:latest".to_string()); } + #[test] + fn test_image_from_dockerfile_resolves_one_hop_alias() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_resolves_deep_alias_chain() { + let dockerfile = + "FROM ubuntu:24.04 AS base\nFROM base AS mid\nFROM mid AS development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_no_target_resolves_alias() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &None), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_stage_alias_shadows_external_image() { + // The first `a` is an external image: stage `a` isn't defined yet. + // Target `a` builds from stage `b`, whose base is that external `a`. + let dockerfile = "FROM a AS b\nFROM b AS a".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("a".to_string())), + Some("a".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_only_resolves_earlier_stages() { + // The first `ubuntu` is the external image, not the later stage. + let dockerfile = "FROM ubuntu AS build\nFROM debian AS ubuntu".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("build".to_string())), + Some("ubuntu".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_skips_platform_flag() { + let dockerfile = + "FROM --platform=linux/amd64 ubuntu:24.04 AS base\nFROM base AS development" + .to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_missing_target() { + let dockerfile = "FROM ubuntu:24.04 AS base".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("nonexistent".to_string())), + None + ); + } + + #[test] + fn test_image_from_dockerfile_case_insensitive_alias() { + let dockerfile = "FROM ubuntu:24.04 AS Base\nFROM Base AS Development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_scratch_base() { + let dockerfile = "FROM scratch AS builder\nFROM builder AS final".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("final".to_string())), + Some("scratch".to_string()) + ); + } + #[gpui::test] async fn test_expands_args_in_dockerfile(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -6090,13 +6305,62 @@ RUN echo $RUBY_VERSION2 } #[test] - fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() {} + fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + "FROM ubuntu:24.04 AS base\nFROM base AS development\nFROM development AS dev_container_auto_added_stage_label" + ); + } + + #[test] + fn test_aliases_dockerfile_with_no_aliases_for_build() { + let dockerfile = "FROM --platform=linux/amd64 ubuntu:24.04\nRUN echo ok"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + "FROM --platform=linux/amd64 ubuntu:24.04 AS dev_container_auto_added_stage_label\nRUN echo ok" + ); + } #[test] - fn test_aliases_dockerfile_with_no_aliases_for_build() {} + fn test_aliases_dockerfile_with_build_target_specified() { + let dockerfile = "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production"; + + assert_eq!( + dockerfile_inject_alias( + dockerfile, + "dev_container_auto_added_stage_label", + Some("development".to_string()) + ), + "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production\nFROM development AS dev_container_auto_added_stage_label" + ); + } #[test] - fn test_aliases_dockerfile_with_build_target_specified() {} + fn test_aliases_dockerfile_with_missing_build_target_is_unmodified() { + let dockerfile = "FROM ubuntu:24.04 AS development"; + + assert_eq!( + dockerfile_inject_alias( + dockerfile, + "dev_container_auto_added_stage_label", + Some("nonexistent".to_string()) + ), + dockerfile + ); + } + + #[test] + fn test_aliases_dockerfile_with_line_continuation_is_unmodified() { + let dockerfile = "FROM ubuntu:24.04 \\\n --platform=linux/amd64\nRUN echo ok"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + dockerfile + ); + } pub(crate) struct RecordedExecCommand { pub(crate) _container_id: String, diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 963c5a57a29e3a..f34a81bef77ea8 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -1579,11 +1579,12 @@ fn dispatch_apply_templates( }; if files.project_files.contains(&Arc::from( - RelPath::unix(".devcontainer/devcontainer.json").unwrap(), + RelPath::from_unix_str(".devcontainer/devcontainer.json").unwrap(), )) { let Some(workspace_task) = workspace .update_in(cx, |workspace, window, cx| { - let Ok(path) = RelPath::unix(".devcontainer/devcontainer.json") else { + let Ok(path) = RelPath::from_unix_str(".devcontainer/devcontainer.json") + else { return Task::ready(Err(anyhow!( "Couldn't create path for .devcontainer/devcontainer.json" ))); diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 9f243c781021cd..724d58e3fc6248 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -74,6 +74,7 @@ impl Render for DiagnosticIndicator { Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) .truncate(true) + .tab_index(0isize) .tooltip(move |_window, cx| { Tooltip::for_action( tooltip, @@ -89,10 +90,32 @@ impl Render for DiagnosticIndicator { None }; + let diagnostics_label = match (self.summary.error_count, self.summary.warning_count) { + (0, 0) => "Project diagnostics: no problems".to_string(), + (errors, warnings) => { + let mut parts = Vec::new(); + if errors > 0 { + parts.push(format!( + "{errors} error{}", + if errors == 1 { "" } else { "s" } + )); + } + if warnings > 0 { + parts.push(format!( + "{warnings} warning{}", + if warnings == 1 { "" } else { "s" } + )); + } + format!("Project diagnostics: {}", parts.join(", ")) + } + }; + indicator .child( ButtonLike::new("diagnostic-indicator") .child(diagnostic_indicator) + .tab_index(0isize) + .aria_label(diagnostics_label) .tooltip(move |_window, cx| { Tooltip::for_action("Project Diagnostics", &Deploy, cx) }) diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index 87e5110ff52b6e..a422d2811dbb6d 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -27,4 +27,4 @@ workspace = true [[bin]] name = "docs_preprocessor" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/crates/docs_preprocessor/src/ai_discovery.rs b/crates/docs_preprocessor/src/ai_discovery.rs new file mode 100644 index 00000000000000..4f3f919582737f --- /dev/null +++ b/crates/docs_preprocessor/src/ai_discovery.rs @@ -0,0 +1,549 @@ +use anyhow::{Context, Result}; +use mdbook::BookItem; +use mdbook::book::Book; +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::FRONT_MATTER_COMMENT; + +#[derive(Debug)] +pub(crate) struct DocsPage { + section: String, + title: String, + description: Option, + pub(crate) source_path: PathBuf, + content: String, +} + +pub(crate) fn write_ai_discovery_artifacts( + pages: &[DocsPage], + destination: &Path, + site_url: &str, +) -> Result<()> { + copy_markdown_sources(destination, site_url, pages)?; + write_llms_txt(destination, site_url, pages)?; + write_sitemap_xml(destination, site_url, pages)?; + Ok(()) +} + +pub(crate) fn docs_pages(book: &Book) -> Result> { + let mut pages = Vec::new(); + let mut section = "Docs".to_string(); + for item in book.iter() { + let BookItem::Chapter(chapter) = item else { + if let BookItem::PartTitle(part_title) = item { + section.clone_from(part_title); + } + continue; + }; + let Some(source_path) = chapter.source_path.as_ref() else { + continue; + }; + if source_path == Path::new("SUMMARY.md") { + continue; + } + pages.push(DocsPage { + section: section.clone(), + title: chapter.name.clone(), + description: docs_page_description(&chapter.content), + source_path: source_path.clone(), + content: chapter.content.clone(), + }); + } + Ok(pages) +} + +fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + for page in pages { + let destination = destination.join(&page.source_path); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create markdown destination {}", parent.display()) + })?; + } + let contents = rewrite_docs_links(&markdown_source_contents(&page.content), site_url); + std::fs::write( + &destination, + add_llms_markdown_directive(&contents, site_url), + ) + .with_context(|| { + format!( + "failed to write markdown page {} to {}", + page.source_path.display(), + destination.display() + ) + })?; + } + let getting_started = destination.join("getting-started.md"); + if getting_started.exists() { + std::fs::copy(&getting_started, destination.join("index.md")) + .context("failed to write index.md markdown alias")?; + } + Ok(()) +} + +fn markdown_source_contents(contents: &str) -> String { + front_matter_comment_regex() + .replace(contents, "") + .trim_start() + .to_string() +} + +fn docs_page_description(contents: &str) -> Option { + docs_page_metadata(contents).and_then(|metadata| { + metadata + .get("description") + .map(|description| { + description + .trim() + .trim_matches('"') + .split_whitespace() + .collect::>() + .join(" ") + }) + .filter(|description| !description.is_empty()) + }) +} + +fn docs_page_metadata(contents: &str) -> Option> { + let captures = front_matter_comment_regex().captures(contents)?; + serde_json::from_str(&captures[1]).ok() +} + +fn front_matter_comment_regex() -> &'static Regex { + static FRONT_MATTER_COMMENT_REGEX: OnceLock = OnceLock::new(); + FRONT_MATTER_COMMENT_REGEX + .get_or_init(|| Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "([^\\n]*)")).unwrap()) +} + +fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("# Zed Docs\n\n"); + contents.push_str( + "> Official Zed documentation index with links to Markdown versions of each docs page.\n\n", + ); + contents.push_str( + "Use these links for concise Markdown copies of Zed documentation pages. Each linked page mirrors the corresponding `/docs/*.html` page without site navigation or styling.\n\n", + ); + let mut current_section = None; + for page in pages { + if current_section != Some(page.section.as_str()) { + if current_section.is_some() { + contents.push('\n'); + } + contents.push_str("## "); + contents.push_str(&markdown_text(&page.section)); + contents.push_str("\n\n"); + current_section = Some(page.section.as_str()); + } + contents.push_str("- ["); + contents.push_str(&markdown_text(&page.title)); + contents.push_str("]("); + contents.push_str(&absolute_docs_url(site_url, &page.source_path)); + contents.push(')'); + if let Some(description) = &page.description { + contents.push_str(": "); + contents.push_str(&markdown_text(description)); + } + contents.push('\n'); + } + std::fs::write(destination.join("llms.txt"), contents).context("failed to write llms.txt")?; + Ok(()) +} + +fn markdown_text(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") +} + +fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("\n"); + contents.push_str("\n"); + for page in pages { + contents.push_str(" "); + contents.push_str(&xml_escape(&absolute_docs_url( + site_url, + &page.source_path.with_extension("html"), + ))); + contents.push_str(""); + contents.push_str("\n"); + } + contents.push_str("\n"); + std::fs::write(destination.join("sitemap.xml"), contents) + .context("failed to write sitemap.xml")?; + Ok(()) +} + +pub(crate) fn write_pages_redirects( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + let Some(deploy_root) = destination.parent() else { + return Ok(()); + }; + let mut contents = String::new(); + for (source, destination) in redirects { + write_redirect_line( + &mut contents, + &docs_path("/docs/", source), + &redirect_destination(site_url, destination), + ); + if let Some(extensionless_source) = strip_html_suffix(source) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &extensionless_source), + &redirect_destination( + site_url, + &strip_html_suffix(destination).unwrap_or_else(|| destination.to_string()), + ), + ); + } + if let Some(markdown_source) = html_path_to_markdown(source) { + if let Some(markdown_destination) = html_path_to_markdown(destination) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &markdown_source), + &redirect_destination(site_url, &markdown_destination), + ); + } + } + } + std::fs::write(deploy_root.join("_redirects"), contents) + .context("failed to write Cloudflare Pages _redirects")?; + Ok(()) +} + +pub(crate) fn write_markdown_redirect_aliases( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + for (source, redirect_destination_path) in redirects { + let Some(source_markdown) = html_path_to_markdown(source) else { + continue; + }; + let Some(destination_markdown) = html_path_to_markdown(redirect_destination_path) else { + continue; + }; + let source_markdown = destination.join(source_markdown.trim_start_matches('/')); + let destination_markdown = + destination.join(destination_markdown.trim_start_matches("/docs/")); + if !destination_markdown.exists() { + continue; + } + if let Some(parent) = source_markdown.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create markdown alias directory {}", + parent.display() + ) + })?; + } + let contents = format!( + "# Moved\n\n> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\nThis page moved to [the current docs page]({}).\n", + docs_url(site_url, Path::new("llms.txt")), + html_path_to_markdown(redirect_destination_path) + .map(|path| redirect_destination(site_url, &path)) + .unwrap_or_else(|| redirect_destination(site_url, redirect_destination_path)) + ); + std::fs::write(&source_markdown, contents).with_context(|| { + format!( + "failed to write markdown redirect alias from {} to {}", + redirect_destination_path, + source_markdown.display() + ) + })?; + } + Ok(()) +} + +fn write_redirect_line(contents: &mut String, source: &str, destination: &str) { + contents.push_str(source); + contents.push(' '); + contents.push_str(destination); + contents.push_str(" 301\n"); +} + +fn docs_path(site_url: &str, path: &str) -> String { + docs_url(site_url, Path::new(path.trim_start_matches('/'))) +} + +fn redirect_destination(site_url: &str, destination: &str) -> String { + if let Some(path) = destination.strip_prefix("/docs/") { + docs_url(site_url, Path::new(path)) + } else if destination == "/docs" { + docs_url(site_url, Path::new("")) + } else { + destination.to_string() + } +} + +fn strip_html_suffix(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + let path = path.strip_suffix(".html")?; + Some(format!("{path}{fragment}")) +} + +fn html_path_to_markdown(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + if !path.starts_with("/docs/") && path != "/docs" && !path.ends_with(".html") { + return None; + } + let markdown_path = path.strip_suffix(".html").unwrap_or(path); + Some(format!("{markdown_path}.md{fragment}")) +} + +fn split_fragment(path: &str) -> (&str, &str) { + match path.find('#') { + Some(index) => (&path[..index], &path[index..]), + None => (path, ""), + } +} + +pub(crate) fn rewrite_docs_links(contents: &str, site_url: &str) -> String { + const STABLE_DOCS_PREFIX: &str = "https://zed.dev/docs/"; + let channel_docs_prefix = absolute_docs_url(site_url, Path::new("")); + if channel_docs_prefix == STABLE_DOCS_PREFIX { + return contents.to_string(); + } + + let mut output = String::with_capacity(contents.len()); + let mut remaining = contents; + while let Some(index) = remaining.find(STABLE_DOCS_PREFIX) { + output.push_str(&remaining[..index]); + let after_prefix = &remaining[index + STABLE_DOCS_PREFIX.len()..]; + if after_prefix.starts_with("preview/") || after_prefix.starts_with("nightly/") { + output.push_str(STABLE_DOCS_PREFIX); + } else { + output.push_str(&channel_docs_prefix); + } + remaining = after_prefix; + } + output.push_str(remaining); + output +} + +pub(crate) fn add_markdown_alternate_link( + contents: &str, + html_file: &Path, + root_dir: &Path, + site_url: &str, +) -> String { + let Ok(relative_path) = html_file.strip_prefix(root_dir) else { + return contents.to_string(); + }; + let markdown_path = relative_path.with_extension("md"); + if !root_dir.join(&markdown_path).exists() { + return contents.to_string(); + } + let markdown_url = docs_url(site_url, &markdown_path); + let link = format!( + " \n", + markdown_url + ); + contents.replacen("", &(link + " "), 1) +} + +fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String { + let directive = format!( + "> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\n", + docs_url(site_url, Path::new("llms.txt")), + ); + if let Some(rest) = contents.strip_prefix("---\n") { + if let Some(frontmatter_end) = rest.find("\n---\n") { + let split_at = "---\n".len() + frontmatter_end + "\n---\n".len(); + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&contents[..split_at]); + output.push('\n'); + output.push_str(&directive); + output.push_str(&contents[split_at..]); + return output; + } + } + + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&directive); + output.push_str(contents); + output +} + +fn docs_url(site_url: &str, path: &Path) -> String { + let mut url = site_url.to_string(); + if !url.ends_with('/') { + url.push('/'); + } + url.push_str(&path.to_string_lossy().replace('\\', "/")); + url +} + +fn absolute_docs_url(site_url: &str, path: &Path) -> String { + let url = docs_url(site_url, path); + if url.starts_with("http://") || url.starts_with("https://") { + url + } else { + format!("https://zed.dev{}", url) + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_llms_markdown_directive_inserts_after_frontmatter() { + let contents = "---\ntitle: Example\n---\n# Example\n"; + let output = add_llms_markdown_directive(contents, "/docs/"); + + assert!(output.starts_with("---\ntitle: Example\n---\n\n")); + assert!(output.contains( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt)." + )); + } + + #[test] + fn test_redirect_destination_uses_channel_site_url_for_docs_paths() { + assert_eq!( + redirect_destination("/docs/preview/", "/docs/ai/overview.html"), + "/docs/preview/ai/overview.html" + ); + assert_eq!( + redirect_destination("/docs/preview/", "/community-links"), + "/community-links" + ); + } + + #[test] + fn test_rewrite_docs_links_uses_channel_site_url() { + assert_eq!( + rewrite_docs_links( + "See [Code Actions](https://zed.dev/docs/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html).", + "/docs/preview/" + ), + "See [Code Actions](https://zed.dev/docs/preview/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html)." + ); + } + + #[test] + fn test_docs_path_uses_channel_site_url() { + assert_eq!( + docs_path("/docs/preview/", "/assistant.md"), + "/docs/preview/assistant.md" + ); + } + + #[test] + fn test_write_pages_redirects_keeps_sources_on_internal_pages_path() -> Result<()> { + let deploy_root = std::env::temp_dir().join(format!( + "docs_preprocessor_pages_redirects_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + let destination = deploy_root.join("docs"); + std::fs::create_dir_all(&destination)?; + let redirects = vec![ + ( + "/assistant.html".to_string(), + "/docs/ai/overview.html".to_string(), + ), + ( + "/community/feedback.html".to_string(), + "/community-links".to_string(), + ), + ]; + + write_pages_redirects(&destination, &redirects, "/docs/preview/")?; + + assert_eq!( + std::fs::read_to_string(deploy_root.join("_redirects"))?, + "/docs/assistant.html /docs/preview/ai/overview.html 301\n\ +/docs/assistant /docs/preview/ai/overview 301\n\ +/docs/assistant.md /docs/preview/ai/overview.md 301\n\ +/docs/community/feedback.html /community-links 301\n\ +/docs/community/feedback /community-links 301\n" + ); + std::fs::remove_dir_all(&deploy_root)?; + Ok(()) + } + + #[test] + fn test_write_ai_discovery_artifacts_generates_agent_facing_metadata() -> Result<()> { + let destination = std::env::temp_dir().join(format!( + "docs_preprocessor_ai_discovery_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&destination)?; + + let pages = vec![ + DocsPage { + section: "Docs".to_string(), + title: "Getting Started".to_string(), + description: Some("Start using Zed.".to_string()), + source_path: PathBuf::from("getting-started.md"), + content: format!( + "{}\n# Getting Started\n", + FRONT_MATTER_COMMENT.replace("{}", r#"{"description":"Start using Zed."}"#) + ), + }, + DocsPage { + section: "AI".to_string(), + title: "MCP".to_string(), + description: Some("Connect model context servers.".to_string()), + source_path: PathBuf::from("ai/mcp.md"), + content: format!( + "{}\n# MCP\n", + FRONT_MATTER_COMMENT + .replace("{}", r#"{"description":"Connect model context servers."}"#) + ), + }, + ]; + + write_ai_discovery_artifacts(&pages, &destination, "/docs/")?; + + let llms_txt = std::fs::read_to_string(destination.join("llms.txt"))?; + assert!(llms_txt.contains("## Docs")); + assert!(llms_txt.contains( + "- [Getting Started](https://zed.dev/docs/getting-started.md): Start using Zed." + )); + assert!(llms_txt.contains("## AI")); + assert!( + llms_txt.contains( + "- [MCP](https://zed.dev/docs/ai/mcp.md): Connect model context servers." + ) + ); + + let sitemap_xml = std::fs::read_to_string(destination.join("sitemap.xml"))?; + assert!(sitemap_xml.contains("https://zed.dev/docs/getting-started.html")); + assert!(sitemap_xml.contains("https://zed.dev/docs/ai/mcp.html")); + + let mcp_markdown = std::fs::read_to_string(destination.join("ai/mcp.md"))?; + assert!(mcp_markdown.starts_with( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt).\n\n# MCP" + )); + assert!(!mcp_markdown.contains("ZED_META")); + + let index_markdown = std::fs::read_to_string(destination.join("index.md"))?; + assert!(index_markdown.contains("# Getting Started")); + + std::fs::remove_dir_all(&destination)?; + Ok(()) + } +} diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 12b4a73f2f7996..4b0c2ca04bcb90 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -1,4 +1,10 @@ use anyhow::{Context, Result}; +mod ai_discovery; + +use ai_discovery::{ + add_markdown_alternate_link, docs_pages, rewrite_docs_links, write_ai_discovery_artifacts, + write_markdown_redirect_aliases, write_pages_redirects, +}; use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; @@ -188,7 +194,7 @@ fn handle_preprocessing() -> Result<()> { template_big_table_of_actions(&mut book); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); - template_and_validate_json_snippets(&mut book, &mut errors); + template_and_validate_json_snippets(&mut book, &mut errors)?; if !errors.is_empty() { const ANSI_RED: &str = "\x1b[31m"; @@ -252,7 +258,7 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -300,7 +306,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#action (.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#action\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -379,7 +385,10 @@ fn find_binding_with_overlay( .or_else(|| find_binding(os, action)) } -fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { +fn template_and_validate_json_snippets( + book: &mut Book, + errors: &mut HashSet, +) -> Result<()> { let params = SettingsJsonSchemaParams { language_names: &[], font_names: &[], @@ -393,12 +402,23 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 {
-                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
-                    snippet_json_fixed.insert(0, '[');
-                    snippet_json_fixed.push_str("\n]");
-                }
+                if let Some(keymap_validator) = &keymap_validator {
+                    if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
+                        snippet_json_fixed.insert(0, '[');
+                        snippet_json_fixed.push_str("\n]");
+                    }
 
-                let value =
-                    settings::parse_json_with_comments::(&snippet_json_fixed)?;
-                let validation_errors: Vec = keymap_validator
-                    .iter_errors(&value)
-                    .map(|err| err.to_string())
-                    .collect();
-                if !validation_errors.is_empty() {
-                    anyhow::bail!("{}", validation_errors.join("\n"));
+                    let value = settings::parse_json_with_comments::(
+                        &snippet_json_fixed,
+                    )?;
+                    let validation_errors: Vec = keymap_validator
+                        .iter_errors(&value)
+                        .map(|err| err.to_string())
+                        .collect();
+                    if !validation_errors.is_empty() {
+                        anyhow::bail!("{}", validation_errors.join("\n"));
+                    }
                 }
             }
             "debug" => {
@@ -554,6 +577,8 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 Result<()> {
         .as_table_mut()
         .expect("output is table");
     let zed_html = output.remove("zed-html").expect("zed-html output defined");
+    let redirects = zed_html
+        .get("redirect")
+        .and_then(|redirects| redirects.as_table())
+        .map(|redirects| {
+            redirects
+                .iter()
+                .filter_map(|(source, destination)| {
+                    destination
+                        .as_str()
+                        .map(|destination| (source.clone(), destination.to_string()))
+                })
+                .collect::>()
+        });
     let default_description = zed_html
         .get("default-description")
         .expect("Default description not found")
@@ -680,6 +718,17 @@ fn handle_postprocessing() -> Result<()> {
     let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
     let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
     let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
+    let site_url = std::env::var("MDBOOK_BOOK__SITE_URL")
+        .ok()
+        .filter(|site_url| !site_url.trim().is_empty())
+        .unwrap_or_else(|| {
+            match docs_channel.as_str() {
+                "nightly" => "/docs/nightly/",
+                "preview" => "/docs/preview/",
+                _ => "/docs/",
+            }
+            .to_string()
+        });
     let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
         ""
     } else {
@@ -719,8 +768,10 @@ fn handle_postprocessing() -> Result<()> {
     }
 
     zlog::info!(logger => "Processing {} `.html` files", files.len());
+    let pages = docs_pages(&ctx.book)?;
+    write_ai_discovery_artifacts(&pages, &root_dir, &site_url)?;
     let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
-    for file in files {
+    for file in &files {
         let contents = std::fs::read_to_string(&file)?;
         let mut meta_description = None;
         let mut meta_title = None;
@@ -756,14 +807,19 @@ fn handle_postprocessing() -> Result<()> {
         let contents = contents.replace("#amplitude_key#", &litude_key);
         let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
         let contents = contents.replace("#noindex#", noindex);
+        let contents = rewrite_docs_links(&contents, &site_url);
+        let contents = add_markdown_alternate_link(&contents, file, &root_dir, &site_url);
         let contents = title_regex()
             .replace(&contents, |_: ®ex::Captures| {
                 format!("{}", meta_title)
             })
             .to_string();
-        // let contents = contents.replace("#title#", &meta_title);
         std::fs::write(file, contents)?;
     }
+    if let Some(redirects) = redirects {
+        write_markdown_redirect_aliases(&root_dir, &redirects, &site_url)?;
+        write_pages_redirects(&root_dir, &redirects, &site_url)?;
+    }
     return Ok(());
 
     fn pretty_path<'a>(
@@ -892,66 +948,4 @@ fn keymap_schema_for_actions(
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use serde_json::json;
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_over_parameterized() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-tab": "sidebar::ToggleThreadSwitcher",
-                    "ctrl-shift-tab": ["sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_falls_back_to_parameterized_match() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_regardless_of_order() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["sidebar::ToggleThreadSwitcher", { "select_last": true }],
-                    "ctrl-tab": "sidebar::ToggleThreadSwitcher"
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_later_section_overrides_earlier() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            { "bindings": { "ctrl-a": "some::Action" } },
-            { "bindings": { "ctrl-b": "some::Action" } }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "some::Action");
-        assert_eq!(binding.as_deref(), Some("ctrl-b"));
-    }
-}
+mod tests;
diff --git a/crates/docs_preprocessor/src/tests.rs b/crates/docs_preprocessor/src/tests.rs
new file mode 100644
index 00000000000000..a393cd1c42b1b6
--- /dev/null
+++ b/crates/docs_preprocessor/src/tests.rs
@@ -0,0 +1,61 @@
+use super::*;
+use serde_json::json;
+
+#[test]
+fn test_find_binding_prefers_exact_match_over_parameterized() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-tab": "sidebar::ToggleThreadSwitcher",
+                "ctrl-shift-tab": ["sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_falls_back_to_parameterized_match() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
+}
+
+#[test]
+fn test_find_binding_prefers_exact_match_regardless_of_order() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["sidebar::ToggleThreadSwitcher", { "select_last": true }],
+                "ctrl-tab": "sidebar::ToggleThreadSwitcher"
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_later_section_overrides_earlier() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        { "bindings": { "ctrl-a": "some::Action" } },
+        { "bindings": { "ctrl-b": "some::Action" } }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "some::Action");
+    assert_eq!(binding.as_deref(), Some("ctrl-b"));
+}
diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs
index cef4a63e33c8bb..965d8bc2699171 100644
--- a/crates/edit_prediction/src/edit_prediction.rs
+++ b/crates/edit_prediction/src/edit_prediction.rs
@@ -929,6 +929,17 @@ fn predict_edits_request_trigger_from_editor_trigger(
         EditPredictionRequestTrigger::PredictionPartiallyAccepted => {
             PredictEditsRequestTrigger::PredictionPartiallyAccepted
         }
+        EditPredictionRequestTrigger::EditorCreated => PredictEditsRequestTrigger::EditorCreated,
+        EditPredictionRequestTrigger::ProviderChanged => {
+            PredictEditsRequestTrigger::ProviderChanged
+        }
+        EditPredictionRequestTrigger::UserInfoChanged => {
+            PredictEditsRequestTrigger::UserInfoChanged
+        }
+        EditPredictionRequestTrigger::VimModeChanged => PredictEditsRequestTrigger::VimModeChanged,
+        EditPredictionRequestTrigger::SettingsChanged => {
+            PredictEditsRequestTrigger::SettingsChanged
+        }
         EditPredictionRequestTrigger::Other => PredictEditsRequestTrigger::Other,
     }
 }
diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs
index a2a196d1352180..dd8b866a6b1a1e 100644
--- a/crates/edit_prediction/src/edit_prediction_tests.rs
+++ b/crates/edit_prediction/src/edit_prediction_tests.rs
@@ -1389,7 +1389,7 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
     let (request, respond_tx) = requests.predict.next().await.unwrap();
 
     buffer.update(cx, |buffer, cx| {
-        buffer.set_text("Hello!\nHow are you?\nBye", cx);
+        buffer.edit([(10..10, " are you?")], None, cx);
     });
 
     let mut response = model_response(&request, SIMPLE_DIFF);
@@ -1412,7 +1412,6 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
         assert!(shown_predictions[0].editable_range.is_some());
     });
 
-    // prediction is reported as rejected
     let (reject_request, _) = requests.reject.next().await.unwrap();
 
     assert_eq!(
@@ -1427,6 +1426,75 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_interpolate_failed(cx: &mut TestAppContext) {
+    let (ep_store, mut requests) = init_test_with_fake_client(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "foo.md":  "Hello!\nHow\nBye\n"
+        }),
+    )
+    .await;
+    let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            let path = project.find_project_path(path!("root/foo.md"), cx).unwrap();
+            project.open_buffer(path, cx)
+        })
+        .await
+        .unwrap();
+    let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+    let position = snapshot.anchor_before(language::Point::new(1, 3));
+
+    ep_store.update(cx, |ep_store, cx| {
+        ep_store.refresh_prediction_from_buffer(
+            project.clone(),
+            buffer.clone(),
+            position,
+            EditPredictionRequestTrigger::Other,
+            cx,
+        );
+    });
+
+    let (request, respond_tx) = requests.predict.next().await.unwrap();
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit([(10..10, " is it?")], None, cx);
+    });
+
+    let mut response = model_response(&request, SIMPLE_DIFF);
+    response.model_version = Some("zeta2:test-interpolate-failed".to_string());
+    let id = response.request_id.clone();
+    respond_tx.send(response).unwrap();
+
+    cx.run_until_parked();
+
+    ep_store.update(cx, |ep_store, cx| {
+        assert!(
+            ep_store
+                .prediction_at(&buffer, None, &project, cx)
+                .is_none()
+        );
+        assert!(ep_store.rateable_predictions().next().is_none());
+    });
+
+    let (reject_request, _) = requests.reject.next().await.unwrap();
+
+    assert_eq!(
+        &reject_request.rejections,
+        &[EditPredictionRejection {
+            request_id: id,
+            reason: EditPredictionRejectReason::InterpolateFailed,
+            was_shown: false,
+            model_version: Some("zeta2:test-interpolate-failed".to_string()),
+            e2e_latency_ms: Some(0),
+        }]
+    );
+}
+
 const SIMPLE_DIFF: &str = indoc! { r"
     --- a/root/foo.md
     +++ b/root/foo.md
diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs
index ca6379fa6eecf5..592d5e3b1af13d 100644
--- a/crates/edit_prediction/src/prediction.rs
+++ b/crates/edit_prediction/src/prediction.rs
@@ -49,27 +49,35 @@ impl EditPredictionResult {
         e2e_latency: std::time::Duration,
         cx: &mut AsyncApp,
     ) -> Self {
-        let (edits, new_snapshot) = (!edits.is_empty())
-            .then(|| {
-                edited_buffer.read_with(cx, |buffer, _cx| {
-                    let new_snapshot = buffer.snapshot();
-                    let edits: Arc<[(Range, Arc)]> =
-                        interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)
-                            .map(Arc::from)
-                            .unwrap_or_default();
-                    let snapshot = (!edits.is_empty()).then_some(new_snapshot);
-                    (Some(edits), snapshot)
-                })
+        let (edits, reject_reason, new_snapshot): (
+            Arc<[(Range, Arc)]>,
+            Option,
+            Option,
+        ) = if edits.is_empty() {
+            (
+                Arc::default(),
+                Some(EditPredictionRejectReason::Empty),
+                None,
+            )
+        } else {
+            edited_buffer.read_with(cx, |buffer, _cx| {
+                let new_snapshot = buffer.snapshot();
+                match interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits) {
+                    Some(edits) if edits.is_empty() => (
+                        Arc::default(),
+                        Some(EditPredictionRejectReason::InterpolatedEmpty),
+                        None,
+                    ),
+                    Some(edits) => (Arc::from(edits), None, Some(new_snapshot)),
+                    None => (
+                        Arc::default(),
+                        Some(EditPredictionRejectReason::InterpolateFailed),
+                        None,
+                    ),
+                }
             })
-            .unwrap_or_default();
-        let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone());
-
-        let reject_reason = match edits.as_ref() {
-            None => Some(EditPredictionRejectReason::Empty),
-            Some(edits) if edits.is_empty() => Some(EditPredictionRejectReason::InterpolatedEmpty),
-            Some(_) => None,
         };
-        let edits = edits.unwrap_or_default();
+        let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone());
 
         let edit_preview = if !edits.is_empty() {
             edited_buffer
@@ -96,6 +104,34 @@ impl EditPredictionResult {
             e2e_latency,
         }
     }
+
+    pub fn new_rejected(
+        id: EditPredictionId,
+        edited_buffer: &Entity,
+        edited_buffer_snapshot: &BufferSnapshot,
+        inputs: EditPredictionInputs,
+        model_version: Option,
+        trigger: PredictEditsRequestTrigger,
+        e2e_latency: std::time::Duration,
+        reject_reason: EditPredictionRejectReason,
+    ) -> Self {
+        Self {
+            prediction: EditPrediction {
+                id,
+                edits: Arc::default(),
+                cursor_position: None,
+                editable_range: None,
+                snapshot: edited_buffer_snapshot.clone(),
+                edit_preview: EditPreview::unchanged(edited_buffer_snapshot),
+                inputs,
+                buffer: edited_buffer.clone(),
+                model_version,
+                trigger,
+            },
+            reject_reason: Some(reject_reason),
+            e2e_latency,
+        }
+    }
 }
 
 #[derive(Clone)]
diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs
index 8f095d8bc70489..5606b35e7fd10d 100644
--- a/crates/edit_prediction/src/udiff.rs
+++ b/crates/edit_prediction/src/udiff.rs
@@ -209,7 +209,7 @@ pub async fn apply_diff(
                 if status == FileStatus::Deleted {
                     let delete_task = project.update(cx, |project, cx| {
                         if let Some(path) = project.find_project_path(path.as_ref(), cx) {
-                            project.delete_file(path, false, cx)
+                            project.delete_file(path, cx)
                         } else {
                             None
                         }
@@ -309,12 +309,12 @@ pub async fn refresh_worktree_entries(
 ) -> Result<()> {
     let mut rel_paths = Vec::new();
     for path in paths {
-        if let Ok(rel_path) = RelPath::new(path, PathStyle::Posix) {
+        if let Ok(rel_path) = RelPath::new(path, PathStyle::Unix) {
             rel_paths.push(rel_path.into_arc());
         }
 
         let path_without_root: PathBuf = path.components().skip(1).collect();
-        if let Ok(rel_path) = RelPath::new(&path_without_root, PathStyle::Posix) {
+        if let Ok(rel_path) = RelPath::new(&path_without_root, PathStyle::Unix) {
             rel_paths.push(rel_path.into_arc());
         }
     }
diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
index c3cb556c7b1b4b..b7ae7d629ec9a4 100644
--- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
+++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
@@ -222,13 +222,22 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
 
             let Some(edits) = prediction.interpolate(&snapshot) else {
                 store.reject_current_prediction(
-                    EditPredictionRejectReason::InterpolatedEmpty,
+                    EditPredictionRejectReason::InterpolateFailed,
                     &self.project,
                     cx,
                 );
                 return None;
             };
 
+            if edits.is_empty() {
+                store.reject_current_prediction(
+                    EditPredictionRejectReason::InterpolatedEmpty,
+                    &self.project,
+                    cx,
+                );
+                return None;
+            }
+
             let cursor_row = cursor_position.to_point(&snapshot).row;
             let (closest_edit_ix, (closest_edit_range, _)) =
                 edits.iter().enumerate().min_by_key(|(_, (range, _))| {
diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs
index 009d91c5b57312..54b324253ad71c 100644
--- a/crates/edit_prediction/src/zeta.rs
+++ b/crates/edit_prediction/src/zeta.rs
@@ -9,7 +9,9 @@ use crate::{
     udiff::prediction_edits_for_single_file_diff,
 };
 use anyhow::{Context as _, Result};
-use cloud_llm_client::{AcceptEditPredictionBody, predict_edits_v3::RawCompletionRequest};
+use cloud_llm_client::{
+    AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
+};
 use edit_prediction_types::PredictedCursorPosition;
 use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*};
 use language::{
@@ -516,26 +518,41 @@ pub(crate) fn request_prediction_with_zeta(
                 snapshot: fallback_snapshot,
                 patch,
             } => {
-                let Some((buffer, snapshot, edits, cursor_position)) =
-                    prediction_edits_for_single_file_diff(&patch, &project, cx).await?
-                else {
-                    return Ok(Some(
-                        EditPredictionResult::new(
-                            id,
-                            &fallback_buffer,
-                            &fallback_snapshot,
-                            Arc::new([]),
-                            None,
-                            None,
-                            inputs,
-                            model_version,
-                            trigger,
-                            request_duration,
-                            cx,
-                        )
-                        .await,
-                    ));
-                };
+                let (buffer, snapshot, edits, cursor_position) =
+                    match prediction_edits_for_single_file_diff(&patch, &project, cx).await {
+                        Ok(Some(edits)) => edits,
+                        Ok(None) => {
+                            return Ok(Some(
+                                EditPredictionResult::new(
+                                    id,
+                                    &fallback_buffer,
+                                    &fallback_snapshot,
+                                    Arc::new([]),
+                                    None,
+                                    None,
+                                    inputs,
+                                    model_version,
+                                    trigger,
+                                    request_duration,
+                                    cx,
+                                )
+                                .await,
+                            ));
+                        }
+                        Err(error) => {
+                            log::error!("failed to apply edit prediction patch: {error:?}");
+                            return Ok(Some(EditPredictionResult::new_rejected(
+                                id,
+                                &fallback_buffer,
+                                &fallback_snapshot,
+                                inputs,
+                                model_version,
+                                trigger,
+                                request_duration,
+                                EditPredictionRejectReason::PatchApplyFailed,
+                            )));
+                        }
+                    };
                 let editable_range_in_buffer =
                     edits
                         .iter()
diff --git a/crates/edit_prediction_cli/src/filter_languages.rs b/crates/edit_prediction_cli/src/filter_languages.rs
index cdf503fa23c95d..754ca7d1ba92ec 100644
--- a/crates/edit_prediction_cli/src/filter_languages.rs
+++ b/crates/edit_prediction_cli/src/filter_languages.rs
@@ -514,6 +514,11 @@ mod tests {
             detect_language(".env", &map),
             Some("Shell Script".to_string())
         );
+        // Gentoo ebuild files are a subset of bash
+        assert_eq!(
+            detect_language("app-editors/zed-1.5.4.ebuild", &map),
+            Some("Shell Script".to_string())
+        );
     }
 
     #[test]
diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml
index 244b5f736e5862..f1bcefc4065231 100644
--- a/crates/edit_prediction_context/Cargo.toml
+++ b/crates/edit_prediction_context/Cargo.toml
@@ -26,7 +26,7 @@ serde.workspace = true
 smallvec.workspace = true
 telemetry.workspace = true
 text.workspace = true
-tree-sitter.workspace = true
+tree-sitter = { workspace = true, features = ["wasm"] }
 util.workspace = true
 zeta_prompt.workspace = true
 
@@ -43,4 +43,3 @@ serde_json.workspace = true
 settings = {workspace= true, features = ["test-support"]}
 text = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
-
diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs
index 3069c13b65db4f..d2be8f2ce6ac1d 100644
--- a/crates/edit_prediction_context/src/edit_prediction_context.rs
+++ b/crates/edit_prediction_context/src/edit_prediction_context.rs
@@ -5,8 +5,8 @@ use futures::{FutureExt, StreamExt as _, channel::mpsc, future};
 use gpui::{
     App, AppContext, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, TaskExt, WeakEntity,
 };
-use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _};
-use project::{LocationLink, Project, ProjectPath};
+use language::{Anchor, Bias, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _};
+use project::{EditPredictionDefinition, Project, ProjectPath};
 use smallvec::SmallVec;
 use std::{
     collections::hash_map,
@@ -77,20 +77,15 @@ struct Identifier {
 
 enum DefinitionTask {
     CacheHit(Arc),
-    CacheMiss(
-        Task<
-            Option<(
-                Task>>>,
-                Task>>>,
-            )>,
-        >,
-    ),
+    CacheMiss {
+        project: WeakEntity,
+        task: Task>>,
+    },
 }
 
 #[derive(Debug)]
 struct CacheEntry {
     definitions: SmallVec<[CachedDefinition; 1]>,
-    type_definitions: SmallVec<[CachedDefinition; 1]>,
 }
 
 #[derive(Clone, Debug)]
@@ -183,7 +178,7 @@ impl RelatedExcerptStore {
                     .path
                     .strip_prefix(worktree.root_name().as_unix_str())
                     .ok()?;
-                let relative_path = RelPath::new(relative_path, PathStyle::Posix).ok()?;
+                let relative_path = RelPath::new(relative_path, PathStyle::Unix).ok()?;
                 let project_path = ProjectPath {
                     worktree_id: worktree.id(),
                     path: relative_path.into_owned().into(),
@@ -312,34 +307,21 @@ impl RelatedExcerptStore {
                         DefinitionTask::CacheHit(entry.clone())
                     } else {
                         let project = this.project.clone();
-                        let buffer = buffer.downgrade();
-                        DefinitionTask::CacheMiss(cx.spawn(async move |_, cx| {
-                            let buffer = buffer.upgrade()?;
-                            let definitions = project
-                                .update(cx, |project, cx| {
-                                    project.workspace_definitions(
-                                        &buffer,
-                                        identifier.range.start,
-                                        cx,
-                                    )
-                                })
-                                .ok()?;
-                            let type_definitions = project
-                                .update(cx, |project, cx| {
-                                    // tombi LSP for toml will open a scratch buffer with the JSON schema of
-                                    // the toml file when a goto type definition is requested
-                                    if is_tombi_lsp_in_toml(project, &buffer, cx) {
-                                        return Task::ready(Ok(None));
-                                    }
-                                    project.workspace_type_definitions(
-                                        &buffer,
-                                        identifier.range.start,
-                                        cx,
-                                    )
-                                })
-                                .ok()?;
-                            Some((definitions, type_definitions))
-                        }))
+                        let task = project
+                            .update(cx, |project, cx| {
+                                // tombi LSP for toml will open a scratch buffer with the JSON schema of
+                                // the toml file when a goto type definition is requested
+                                let include_type_definitions =
+                                    !is_tombi_lsp_in_toml(project, &buffer, cx);
+                                project.edit_prediction_definitions(
+                                    &buffer,
+                                    identifier.range.start,
+                                    include_type_definitions,
+                                    cx,
+                                )
+                            })
+                            .unwrap_or_else(|_| Task::ready(Ok(Vec::new())));
+                        DefinitionTask::CacheMiss { project, task }
                     };
 
                     let cx = async_cx.clone();
@@ -348,50 +330,29 @@ impl RelatedExcerptStore {
                             DefinitionTask::CacheHit(cache_entry) => {
                                 Some((identifier, cache_entry, None))
                             }
-                            DefinitionTask::CacheMiss(task) => {
-                                let (definitions, type_definitions) = task.await?;
-                                let (definition_locations, type_definition_locations) =
-                                    futures::join!(definitions, type_definitions);
+                            DefinitionTask::CacheMiss { project, task } => {
+                                let definition_locations = task.await.log_err().unwrap_or_default();
                                 let duration = start_time.elapsed();
 
-                                let definition_locations =
-                                    definition_locations.log_err().flatten().unwrap_or_default();
-                                let type_definition_locations = type_definition_locations
-                                    .log_err()
-                                    .flatten()
-                                    .unwrap_or_default();
-
                                 let definitions: SmallVec<[CachedDefinition; 1]> =
-                                    definition_locations
-                                        .into_iter()
-                                        .filter_map(|location| {
+                                    future::join_all(definition_locations.into_iter().map(
+                                        |definition| {
+                                            let project = project.clone();
                                             let mut cx = cx.clone();
-                                            process_definition(location, &mut cx)
-                                        })
-                                        .collect();
-
-                                let type_definitions: SmallVec<[CachedDefinition; 1]> =
-                                    type_definition_locations
-                                        .into_iter()
-                                        .filter_map(|location| {
-                                            let mut cx = cx.clone();
-                                            process_definition(location, &mut cx)
-                                        })
-                                        .filter(|type_def| {
-                                            !definitions.iter().any(|def| {
-                                                def.buffer.entity_id()
-                                                    == type_def.buffer.entity_id()
-                                                    && def.anchor_range == type_def.anchor_range
-                                            })
-                                        })
-                                        .collect();
+                                            async move {
+                                                process_definition(definition, &project, &mut cx)
+                                                    .await
+                                            }
+                                        },
+                                    ))
+                                    .await
+                                    .into_iter()
+                                    .flatten()
+                                    .collect();
 
                                 Some((
                                     identifier,
-                                    Arc::new(CacheEntry {
-                                        definitions,
-                                        type_definitions,
-                                    }),
+                                    Arc::new(CacheEntry { definitions }),
                                     Some(duration),
                                 ))
                             }
@@ -469,11 +430,7 @@ async fn rebuild_related_files(
     let mut snapshots = HashMap::default();
     let mut worktree_root_names = HashMap::default();
     for entry in new_entries.values() {
-        for definition in entry
-            .definitions
-            .iter()
-            .chain(entry.type_definitions.iter())
-        {
+        for definition in entry.definitions.iter() {
             if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) {
                 definition
                     .buffer
@@ -510,11 +467,7 @@ async fn rebuild_related_files(
                     .get(identifier)
                     .copied()
                     .unwrap_or(usize::MAX);
-                for definition in entry
-                    .definitions
-                    .iter()
-                    .chain(entry.type_definitions.iter())
-                {
+                for definition in entry.definitions.iter() {
                     let Some(snapshot) = snapshots.get(&definition.buffer.entity_id()) else {
                         continue;
                     };
@@ -635,16 +588,24 @@ use language::ToPoint as _;
 
 const MAX_TARGET_LEN: usize = 128;
 
-fn process_definition(location: LocationLink, cx: &mut AsyncApp) -> Option {
+async fn process_definition(
+    definition: EditPredictionDefinition,
+    project: &WeakEntity,
+    cx: &mut AsyncApp,
+) -> Option {
+    let EditPredictionDefinition { path, range } = definition;
+    let buffer = project
+        .update(cx, |project, cx| project.open_buffer(path.clone(), cx))
+        .ok()?
+        .await
+        .log_err()?;
+
     cx.update(|cx| {
-        let buffer = location.target.buffer;
         let buffer_snapshot = buffer.read(cx);
-        let file = buffer_snapshot.file()?;
-        let path = ProjectPath {
-            worktree_id: file.worktree_id(cx),
-            path: file.path().clone(),
-        };
-        let anchor_range = location.target.range;
+        let target_start = buffer_snapshot.clip_point_utf16(range.start, Bias::Left);
+        let target_end = buffer_snapshot.clip_point_utf16(range.end, Bias::Left);
+        let anchor_range =
+            buffer_snapshot.anchor_after(target_start)..buffer_snapshot.anchor_before(target_end);
 
         // If the target range is large, it likely means we requested the definition of an entire module.
         // For individual definitions, the target range should be small as it only covers the symbol.
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 5b8e9c6aecb49c..7c98f7a99268a5 100644
--- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs
+++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs
@@ -5,10 +5,11 @@ use gpui::TestAppContext;
 use indoc::indoc;
 use language::{Point, ToPoint as _, rust_lang};
 use lsp::FakeLanguageServer;
-use project::{FakeFs, LocationLink, Project};
+use project::{FakeFs, LocationLink, Project, ProjectPath};
 use serde_json::json;
 use settings::SettingsStore;
 use std::fmt::Write as _;
+use util::rel_path::rel_path;
 use util::{path, test::marked_text_ranges};
 
 #[gpui::test]
@@ -561,9 +562,10 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) {
 
     // In this project the only identifier near the cursor whose type definition
     // resolves is `TypeA`, and its GotoTypeDefinition returns the exact same
-    // location as GotoDefinition. After deduplication the CacheEntry for `TypeA`
-    // should have an empty `type_definitions` vec, meaning the type-definition
-    // path contributes nothing extra to the related-file output.
+    // location as GotoDefinition. After the definitions and type definitions are
+    // merged and deduped, the type-definition location is dropped, so it
+    // contributes nothing extra to the related-file output (the target location
+    // appears only once).
     fs.insert_tree(
         path!("/root"),
         json!({
@@ -638,6 +640,85 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_edit_prediction_filters_raw_definitions_before_opening_buffers(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "src": {
+                "main.rs": indoc! {"
+                    // fake-definition-lsp-extra target /root/src/large.rs 0 0 0 129
+                    // fake-definition-lsp-extra target /root/src/valid.rs 0 3 0 9
+                    // fake-definition-lsp-extra target /outside.rs 0 3 0 10
+                    fn main() {
+                        target();
+                    }
+                "},
+                "valid.rs": "fn target() {}\n",
+                "large.rs": format!("{}\n", "a".repeat(MAX_TARGET_LEN + 16)),
+            },
+        }),
+    )
+    .await;
+    fs.insert_file(path!("/outside.rs"), "fn outside() {}\n".into())
+        .await;
+
+    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+    let mut servers = setup_fake_lsp(&project, cx);
+
+    let (buffer, _handle) = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    let _fake_language_server = servers.next().await.unwrap();
+    cx.run_until_parked();
+
+    let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(&project, cx));
+    related_excerpt_store.update(cx, |store, cx| {
+        let position = {
+            let buffer = buffer.read(cx);
+            let offset = buffer
+                .text()
+                .find("target();")
+                .expect("target call not found");
+            buffer.anchor_before(offset)
+        };
+
+        store.set_identifier_line_count(0);
+        store.refresh(buffer.clone(), position, cx);
+    });
+
+    cx.executor().advance_clock(DEBOUNCE_DURATION);
+    related_excerpt_store.update(cx, |store, cx| {
+        assert_related_files(
+            &store.related_files(cx),
+            &[
+                ("root/src/valid.rs", &["fn target() {}"]),
+                ("root/src/main.rs", &["fn main() {\n    target();\n}"]),
+            ],
+        );
+    });
+
+    let worktree_id = buffer.read_with(cx, |buffer, cx| {
+        buffer.file().expect("buffer has file").worktree_id(cx)
+    });
+    let valid_path = ProjectPath {
+        worktree_id,
+        path: rel_path("src/valid.rs").into(),
+    };
+    project.read_with(cx, |project, cx| {
+        assert!(project.get_open_buffer(&valid_path, cx).is_some());
+        assert_eq!(project.worktrees(cx).count(), 1);
+    });
+}
+
 #[gpui::test]
 async fn test_definitions_ranked_by_cursor_proximity(cx: &mut TestAppContext) {
     init_test(cx);
diff --git a/crates/edit_prediction_context/src/editable_context.rs b/crates/edit_prediction_context/src/editable_context.rs
index f985b5dbb07630..ce275191417bab 100644
--- a/crates/edit_prediction_context/src/editable_context.rs
+++ b/crates/edit_prediction_context/src/editable_context.rs
@@ -652,7 +652,7 @@ async fn collect_git_log_context(
         .into_iter()
         .enumerate()
     {
-        let Ok(related_path) = RelPath::new(&related_path, PathStyle::Posix) else {
+        let Ok(related_path) = RelPath::new(&related_path, PathStyle::Unix) else {
             continue;
         };
         let project_path = ProjectPath {
diff --git a/crates/edit_prediction_context/src/fake_definition_lsp.rs b/crates/edit_prediction_context/src/fake_definition_lsp.rs
index 5b9e528b63f670..d844597ef50059 100644
--- a/crates/edit_prediction_context/src/fake_definition_lsp.rs
+++ b/crates/edit_prediction_context/src/fake_definition_lsp.rs
@@ -243,6 +243,12 @@ impl DefinitionIndex {
                 .or_insert_with(Vec::new)
                 .push(location);
         }
+        for (name, location) in extract_extra_definition_locations(content) {
+            self.definitions
+                .entry(name)
+                .or_insert_with(Vec::new)
+                .push(location);
+        }
 
         let type_annotations = extract_type_annotations(content)
             .into_iter()
@@ -303,6 +309,34 @@ impl DefinitionIndex {
     }
 }
 
+fn extract_extra_definition_locations(content: &str) -> Vec<(String, lsp::Location)> {
+    content
+        .lines()
+        .filter_map(|line| {
+            let mut parts = line
+                .trim()
+                .strip_prefix("// fake-definition-lsp-extra ")?
+                .split_whitespace();
+            let name = parts.next()?.to_string();
+            let path = PathBuf::from(parts.next()?);
+            let start_row = parts.next()?.parse().ok()?;
+            let start_column = parts.next()?.parse().ok()?;
+            let end_row = parts.next()?.parse().ok()?;
+            let end_column = parts.next()?.parse().ok()?;
+            Some((
+                name,
+                lsp::Location::new(
+                    Uri::from_file_path(path).ok()?,
+                    lsp::Range::new(
+                        lsp::Position::new(start_row, start_column),
+                        lsp::Position::new(end_row, end_column),
+                    ),
+                ),
+            ))
+        })
+        .collect()
+}
+
 /// Extracts `identifier_name -> type_name` mappings from field declarations
 /// and function parameters. For example, `owner: Arc` produces
 /// `"owner" -> "Person"` by unwrapping common generic wrappers.
diff --git a/crates/edit_prediction_metrics/src/reversal.rs b/crates/edit_prediction_metrics/src/reversal.rs
index 80fdc40d53f0e0..00929a2744d6ee 100644
--- a/crates/edit_prediction_metrics/src/reversal.rs
+++ b/crates/edit_prediction_metrics/src/reversal.rs
@@ -4,21 +4,14 @@ use std::path::Path;
 use std::sync::Arc;
 
 use crate::tokenize::tokenize;
-use imara_diff::{
-    Algorithm, diff,
-    intern::{InternedInput, Token},
-    sources::lines_with_terminator,
-};
+use imara_diff::{Algorithm, Diff, InternedInput, Token, sources::lines};
 use zeta_prompt::udiff::apply_diff_to_string;
 
 fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range, Arc)> {
     let empty: Arc = Arc::default();
     let mut edits = Vec::new();
     let mut hunk_input = InternedInput::default();
-    let input = InternedInput::new(
-        lines_with_terminator(old_text),
-        lines_with_terminator(new_text),
-    );
+    let input = InternedInput::new(lines(old_text), lines(new_text));
 
     diff_internal(&input, &mut |old_byte_range,
                                 new_byte_range,
@@ -104,35 +97,34 @@ fn diff_internal(
     let mut old_token_ix = 0;
     let mut new_token_ix = 0;
 
-    diff(
-        Algorithm::Histogram,
-        input,
-        |old_tokens: Range, new_tokens: Range| {
-            old_offset += token_len(
-                input,
-                &input.before[old_token_ix as usize..old_tokens.start as usize],
-            );
-            new_offset += token_len(
-                input,
-                &input.after[new_token_ix as usize..new_tokens.start as usize],
-            );
-            let old_len = token_len(
-                input,
-                &input.before[old_tokens.start as usize..old_tokens.end as usize],
-            );
-            let new_len = token_len(
-                input,
-                &input.after[new_tokens.start as usize..new_tokens.end as usize],
-            );
-            let old_byte_range = old_offset..old_offset + old_len;
-            let new_byte_range = new_offset..new_offset + new_len;
-            old_token_ix = old_tokens.end;
-            new_token_ix = new_tokens.end;
-            old_offset = old_byte_range.end;
-            new_offset = new_byte_range.end;
-            on_change(old_byte_range, new_byte_range, old_tokens, new_tokens);
-        },
-    );
+    let diff = Diff::compute(Algorithm::Histogram, input);
+    for hunk in diff.hunks() {
+        let old_tokens = hunk.before;
+        let new_tokens = hunk.after;
+        old_offset += token_len(
+            input,
+            &input.before[old_token_ix as usize..old_tokens.start as usize],
+        );
+        new_offset += token_len(
+            input,
+            &input.after[new_token_ix as usize..new_tokens.start as usize],
+        );
+        let old_len = token_len(
+            input,
+            &input.before[old_tokens.start as usize..old_tokens.end as usize],
+        );
+        let new_len = token_len(
+            input,
+            &input.after[new_tokens.start as usize..new_tokens.end as usize],
+        );
+        let old_byte_range = old_offset..old_offset + old_len;
+        let new_byte_range = new_offset..new_offset + new_len;
+        old_token_ix = old_tokens.end;
+        new_token_ix = new_tokens.end;
+        old_offset = old_byte_range.end;
+        new_offset = new_byte_range.end;
+        on_change(old_byte_range, new_byte_range, old_tokens, new_tokens);
+    }
 }
 
 fn tokenize_chars(text: &str) -> impl Iterator {
diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs
index a285e8aa70a72e..0a8f3de7dd989a 100644
--- a/crates/edit_prediction_types/src/edit_prediction_types.rs
+++ b/crates/edit_prediction_types/src/edit_prediction_types.rs
@@ -17,6 +17,11 @@ pub enum EditPredictionRequestTrigger {
     LSPCompletionAccepted,
     PredictionAccepted,
     PredictionPartiallyAccepted,
+    EditorCreated,
+    ProviderChanged,
+    UserInfoChanged,
+    VimModeChanged,
+    SettingsChanged,
     #[default]
     Other,
 }
@@ -392,5 +397,5 @@ pub fn interpolate_edits(
 
     edits.extend(model_edits.cloned());
 
-    if edits.is_empty() { None } else { Some(edits) }
+    Some(edits)
 }
diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs
index 46a2dc6c743cd8..703fe9f3d8877e 100644
--- a/crates/edit_prediction_ui/src/edit_prediction_button.rs
+++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs
@@ -108,6 +108,8 @@ impl Render for EditPredictionButton {
                     return div().child(
                         IconButton::new("copilot-error", icon)
                             .icon_size(IconSize::Small)
+                            .tab_index(0isize)
+                            .aria_label("GitHub Copilot")
                             .on_click(cx.listener(move |_, _, window, cx| {
                                 if let Some(workspace) = Workspace::for_window(window, cx) {
                                     workspace.update(cx, |workspace, cx| {
@@ -172,7 +174,9 @@ impl Render for EditPredictionButton {
                         })
                         .anchor(Anchor::BottomRight)
                         .trigger_with_tooltip(
-                            IconButton::new("copilot-icon", icon),
+                            IconButton::new("copilot-icon", icon)
+                                .tab_index(0isize)
+                                .aria_label("GitHub Copilot"),
                             |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx),
                         )
                         .with_handle(self.popover_menu_handle.clone()),
@@ -218,6 +222,8 @@ impl Render for EditPredictionButton {
                         .trigger_with_tooltip(
                             IconButton::new("codestral-icon", IconName::AiMistral)
                                 .shape(IconButtonShape::Square)
+                                .tab_index(0isize)
+                                .aria_label("Edit Prediction")
                                 .when(!has_api_key, |this| {
                                     this.indicator(Indicator::dot().color(Color::Error))
                                         .indicator_border_color(Some(
@@ -262,6 +268,8 @@ impl Render for EditPredictionButton {
                         .trigger(
                             IconButton::new("openai-compatible-api-icon", IconName::AiOpenAiCompat)
                                 .shape(IconButtonShape::Square)
+                                .tab_index(0isize)
+                                .aria_label("Edit Prediction")
                                 .when(!enabled, |this| {
                                     this.indicator(Indicator::dot().color(Color::Ignored))
                                         .indicator_border_color(Some(
@@ -292,6 +300,8 @@ impl Render for EditPredictionButton {
                         .trigger_with_tooltip(
                             IconButton::new("ollama-icon", IconName::AiOllama)
                                 .shape(IconButtonShape::Square)
+                                .tab_index(0isize)
+                                .aria_label("Edit Prediction")
                                 .when(!enabled, |this| {
                                     this.indicator(Indicator::dot().color(Color::Ignored))
                                         .indicator_border_color(Some(
@@ -375,6 +385,8 @@ impl Render for EditPredictionButton {
                     return div().child(
                         IconButton::new("zed-predict-pending-button", ep_icon)
                             .shape(IconButtonShape::Square)
+                            .tab_index(0isize)
+                            .aria_label("Edit Predictions")
                             .indicator(Indicator::dot().color(Color::Muted))
                             .indicator_border_color(Some(cx.theme().colors().status_bar_background))
                             .tooltip(move |_window, cx| {
@@ -430,6 +442,8 @@ impl Render for EditPredictionButton {
 
                 let icon_button = IconButton::new("zed-predict-pending-button", ep_icon)
                     .shape(IconButtonShape::Square)
+                    .tab_index(0isize)
+                    .aria_label("Edit Prediction")
                     .when_some(indicator_color, |this, color| {
                         this.indicator(Indicator::dot().color(color))
                             .indicator_border_color(Some(cx.theme().colors().status_bar_background))
diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
index 0dc4c1aaf29755..23607042c7ba54 100644
--- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs
+++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
@@ -1249,15 +1249,27 @@ impl RatePredictionsModal {
                     PredictEditsRequestTrigger::PredictionPartiallyAccepted => {
                         (IconName::CheckDouble, "Prediction Partially Accepted")
                     }
+                    PredictEditsRequestTrigger::EditorCreated => (IconName::File, "Editor Created"),
+                    PredictEditsRequestTrigger::ProviderChanged => {
+                        (IconName::Settings, "Provider Changed")
+                    }
+                    PredictEditsRequestTrigger::UserInfoChanged => {
+                        (IconName::Person, "User Info Changed")
+                    }
+                    PredictEditsRequestTrigger::VimModeChanged => {
+                        (IconName::Keyboard, "Vim Mode Changed")
+                    }
+                    PredictEditsRequestTrigger::SettingsChanged => {
+                        (IconName::Settings, "Settings Changed")
+                    }
                     PredictEditsRequestTrigger::Other => (IconName::CircleHelp, "Other"),
                 };
 
                 let file = completion.buffer.read(cx).file();
-                let file_name = file
-                    .as_ref()
-                    .map_or(SharedString::new_static("untitled"), |file| {
-                        file.file_name(cx).to_string().into()
-                    });
+                let file_name = file.as_ref().map_or(
+                    SharedString::new_static(MultiBuffer::DEFAULT_TITLE),
+                    |file| file.file_name(cx).to_string().into(),
+                );
                 let file_path = file.map(|file| file.path().as_unix_str().to_string());
 
                 ListItem::new(completion.id.clone())
diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml
index 1ca500832e2807..b6df4a370fc87a 100644
--- a/crates/editor/Cargo.toml
+++ b/crates/editor/Cargo.toml
@@ -98,6 +98,7 @@ unindent = { workspace = true, optional = true }
 ui.workspace = true
 ui_input.workspace = true
 url.workspace = true
+urlencoding.workspace = true
 util.workspace = true
 uuid.workspace = true
 vim_mode_setting.workspace = true
diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs
index 270edbed1d18d7..d88ca2200ff9fe 100644
--- a/crates/editor/src/actions.rs
+++ b/crates/editor/src/actions.rs
@@ -582,16 +582,12 @@ actions!(
         GoToDeclaration,
         /// Goes to declaration in a split pane.
         GoToDeclarationSplit,
-        /// Goes to the definition of the symbol at cursor.
-        GoToDefinition,
         /// Goes to definition in a split pane.
         GoToDefinitionSplit,
         /// Goes to the next diff hunk.
         GoToHunk,
         /// Goes to the previous diff hunk.
         GoToPreviousHunk,
-        /// Goes to the implementation of the symbol at cursor.
-        GoToImplementation,
         /// Goes to implementation in a split pane.
         GoToImplementationSplit,
         /// Goes to the next bookmark in the file.
@@ -669,10 +665,14 @@ actions!(
         MoveToEnd,
         /// Moves cursor to the end of the paragraph.
         MoveToEndOfParagraph,
+        /// Moves cursor to the start of the next comment paragraph.
+        MoveToNextCommentParagraph,
         /// Moves cursor to the end of the next subword.
         MoveToNextSubwordEnd,
         /// Moves cursor to the end of the next word.
         MoveToNextWordEnd,
+        /// Moves cursor to the start of the previous comment paragraph.
+        MoveToPreviousCommentParagraph,
         /// Moves cursor to the start of the previous subword.
         MoveToPreviousSubwordStart,
         /// Moves cursor to the start of the previous word.
@@ -856,6 +856,8 @@ actions!(
         Backtab,
         /// Toggles a bookmark at the current line.
         ToggleBookmark,
+        /// Toggles a bookmark at the current line, prompting for a label when adding one.
+        ToggleBookmarkWithLabel,
         /// Edits the bookmark's label at the current line.
         EditBookmark,
         /// Toggles a breakpoint at the current line.
@@ -945,6 +947,28 @@ actions!(
     ]
 );
 
+/// Goes to the definition of the symbol at cursor.
+#[derive(PartialEq, Clone, Default, Deserialize, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct GoToDefinition {
+    /// Where to show the definitions. Falls back to the `lsp_results_location`
+    /// setting when omitted. A single result is always opened directly.
+    #[serde(default)]
+    pub open_results_in: Option,
+}
+
+/// Goes to the implementation of the symbol at cursor.
+#[derive(PartialEq, Clone, Default, Deserialize, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct GoToImplementation {
+    /// Where to show the implementations. Falls back to the `lsp_results_location`
+    /// setting when omitted. A single result is always opened directly.
+    #[serde(default)]
+    pub open_results_in: Option,
+}
+
 /// Finds all references to the symbol at cursor.
 #[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)]
 #[action(namespace = editor)]
@@ -952,12 +976,17 @@ actions!(
 pub struct FindAllReferences {
     #[serde(default = "default_true")]
     pub always_open_multibuffer: bool,
+    /// Where to show the references. Falls back to the `lsp_results_location`
+    /// setting when omitted. A single result is always opened directly.
+    #[serde(default)]
+    pub open_results_in: Option,
 }
 
 impl Default for FindAllReferences {
     fn default() -> Self {
         Self {
             always_open_multibuffer: true,
+            open_results_in: None,
         }
     }
 }
diff --git a/crates/editor/src/blink_manager.rs b/crates/editor/src/blink_manager.rs
index f265744de3304f..f6ebae5a91786c 100644
--- a/crates/editor/src/blink_manager.rs
+++ b/crates/editor/src/blink_manager.rs
@@ -112,4 +112,9 @@ impl BlinkManager {
     pub fn visible(&self) -> bool {
         self.visible
     }
+
+    #[cfg(test)]
+    pub(crate) fn enabled(&self) -> bool {
+        self.enabled
+    }
 }
diff --git a/crates/editor/src/bookmarks.rs b/crates/editor/src/bookmarks.rs
index e2d1c570d4ea39..12a899f398f777 100644
--- a/crates/editor/src/bookmarks.rs
+++ b/crates/editor/src/bookmarks.rs
@@ -13,7 +13,7 @@ use workspace::{Workspace, searchable::Direction};
 use crate::display_map::DisplayRow;
 use crate::{
     EditBookmark, Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode,
-    SelectionEffects, ToggleBookmark, ViewBookmarks, scroll::Autoscroll,
+    SelectionEffects, ToggleBookmark, ToggleBookmarkWithLabel, ViewBookmarks, scroll::Autoscroll,
 };
 
 #[derive(Clone, Debug)]
@@ -46,6 +46,24 @@ impl Editor {
         _: &ToggleBookmark,
         window: &mut Window,
         cx: &mut Context,
+    ) {
+        self.toggle_bookmark_impl(false, window, cx);
+    }
+
+    pub fn toggle_bookmark_with_label(
+        &mut self,
+        _: &ToggleBookmarkWithLabel,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.toggle_bookmark_impl(true, window, cx);
+    }
+
+    fn toggle_bookmark_impl(
+        &mut self,
+        with_label: bool,
+        window: &mut Window,
+        cx: &mut Context,
     ) {
         let Some(bookmark_store) = self.bookmark_store.clone() else {
             return;
@@ -91,34 +109,27 @@ impl Editor {
         if absent_targets.is_empty() {
             // All cursors are on existing bookmarks, remove all bookmarks.
             self.toggle_bookmarks(exist_targets, String::new(), cx);
-        } else {
-            // Only add new ones and leave existing ones unchanged.
+        } else if with_label {
+            // Only add new ones (prompting for a label) and leave existing ones unchanged.
             self.add_toggle_bookmark_blocks(absent_targets, bookmark_store, window, cx);
+        } else {
+            // Only add new (unnamed) bookmarks and leave existing ones unchanged.
+            self.toggle_bookmarks(absent_targets, String::new(), cx);
         }
 
         cx.notify();
     }
 
-    pub fn toggle_bookmark_at_row(
-        &mut self,
-        row: DisplayRow,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
+    pub fn toggle_bookmark_at_row(&mut self, row: DisplayRow, cx: &mut Context) {
         let display_snapshot = self.display_snapshot(cx);
         let point = display_snapshot.display_point_to_point(row.as_display_point(), Bias::Left);
         let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
         let anchor = buffer_snapshot.anchor_before(point);
 
-        self.toggle_bookmark_at_anchor(anchor, window, cx);
+        self.toggle_bookmark_at_anchor(anchor, cx);
     }
 
-    pub fn toggle_bookmark_at_anchor(
-        &mut self,
-        anchor: Anchor,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
+    pub fn toggle_bookmark_at_anchor(&mut self, anchor: Anchor, cx: &mut Context) {
         let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
         let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else {
             return;
@@ -131,18 +142,9 @@ impl Editor {
             return;
         };
 
-        let target = BookmarkTarget {
-            buffer,
-            anchor,
-            buffer_anchor: position,
-        };
-        if Self::bookmark_exists_for_target(&bookmark_store, &target, cx) {
-            bookmark_store.update(cx, |bookmark_store, cx| {
-                bookmark_store.toggle_bookmark(target.buffer, position, String::new(), cx);
-            });
-        } else {
-            self.add_toggle_bookmark_blocks(vec![target], bookmark_store, window, cx)
-        }
+        bookmark_store.update(cx, |bookmark_store, cx| {
+            bookmark_store.toggle_bookmark(buffer, position, String::new(), cx);
+        });
 
         cx.notify();
     }
diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs
index 8c8c3a36e9a73a..3b90d1e2bc0438 100644
--- a/crates/editor/src/bracket_colorization.rs
+++ b/crates/editor/src/bracket_colorization.rs
@@ -2,15 +2,18 @@
 //! Uses tree-sitter queries from brackets.scm to capture bracket pairs,
 //! and theme accents to colorize those.
 
+use std::cmp::Ordering;
 use std::ops::Range;
+use std::sync::Arc;
 
 use crate::{Editor, HighlightKey};
 use collections::{HashMap, HashSet};
-use gpui::{AppContext as _, Context, HighlightStyle};
+use gpui::{AppContext as _, Context, HighlightStyle, Hsla};
 use language::{BufferRow, BufferSnapshot, language_settings::LanguageSettings};
 use multi_buffer::{Anchor, BufferOffset, ExcerptRange, MultiBufferSnapshot};
 use text::OffsetRangeExt as _;
-use ui::{ActiveTheme, utils::ensure_minimum_contrast};
+use theme::{Appearance, Oklab, Oklch, hsla_to_oklab, hsla_to_oklch, oklch_to_hsla};
+use ui::utils::apca_contrast;
 
 impl Editor {
     pub(crate) fn colorize_brackets(&mut self, invalidate: bool, cx: &mut Context) {
@@ -22,7 +25,10 @@ impl Editor {
             self.bracket_fetched_tree_sitter_chunks.clear();
         }
 
-        let accents_count = cx.theme().accents().0.len();
+        let Some(accent_data) = self.accent_data.as_ref() else {
+            return;
+        };
+        let accents = accent_data.colors.0.clone();
         let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
 
         let visible_excerpts = self.visible_buffer_ranges(cx);
@@ -52,7 +58,12 @@ impl Editor {
             })
             .collect::, HashSet>>>();
 
+        let accents_count = accents.len();
         let bracket_matches_by_accent = cx.background_spawn(async move {
+            if accents_count == 0 {
+                return (HashMap::default(), fetched_tree_sitter_chunks);
+            }
+
             let bracket_matches_by_accent: HashMap>> =
                 excerpt_data.into_iter().fold(
                     HashMap::default(),
@@ -92,9 +103,6 @@ impl Editor {
             (bracket_matches_by_accent, fetched_tree_sitter_chunks)
         });
 
-        let editor_background = cx.theme().colors().editor_background;
-        let accents = cx.theme().accents().clone();
-
         self.colorize_brackets_task = cx.spawn(async move |editor, cx| {
             if invalidate {
                 editor
@@ -115,11 +123,11 @@ impl Editor {
                         .bracket_fetched_tree_sitter_chunks
                         .extend(updated_chunks);
                     for (accent_number, bracket_highlights) in bracket_matches_by_accent {
-                        let bracket_color = accents.color_for_index(accent_number as u32);
-                        let adjusted_color =
-                            ensure_minimum_contrast(bracket_color, editor_background, 55.0);
+                        let Some(&bracket_color) = accents.get(accent_number) else {
+                            continue;
+                        };
                         let style = HighlightStyle {
-                            color: Some(adjusted_color),
+                            color: Some(bracket_color),
                             ..HighlightStyle::default()
                         };
 
@@ -137,6 +145,194 @@ impl Editor {
     }
 }
 
+const BACKGROUND_APCA_LIGHT: f32 = 35.0;
+const BACKGROUND_APCA_DARK: f32 = 30.0;
+const ADJACENT_OKLAB_LIGHT: f32 = 0.10;
+const ADJACENT_OKLAB_DARK: f32 = 0.08;
+const ADJACENT_OKLAB_LIGHT_INTERVENTION: f32 = 0.095;
+const ADJACENT_OKLAB_DARK_INTERVENTION: f32 = 0.08;
+const LIGHTNESS_CLAMP_MIN: f32 = 0.18;
+const LIGHTNESS_CLAMP_MAX: f32 = 0.92;
+
+pub(crate) fn bracket_colorization_accents(
+    accents: &[Hsla],
+    appearance: Appearance,
+    background: Hsla,
+) -> Arc<[Hsla]> {
+    let (intervention_distance, comfortable_distance, min_background_contrast) = match appearance {
+        Appearance::Light => (
+            ADJACENT_OKLAB_LIGHT_INTERVENTION,
+            ADJACENT_OKLAB_LIGHT,
+            BACKGROUND_APCA_LIGHT,
+        ),
+        Appearance::Dark => (
+            ADJACENT_OKLAB_DARK_INTERVENTION,
+            ADJACENT_OKLAB_DARK,
+            BACKGROUND_APCA_DARK,
+        ),
+    };
+    let background_adjusted = accents
+        .iter()
+        .copied()
+        .map(|accent| adjust_color_for_background(accent, background, min_background_contrast))
+        .collect::>();
+    let adjusted_min_adj = min_adjacent_oklab_distance(&background_adjusted, background);
+
+    if accents.len() < 3 || adjusted_min_adj >= intervention_distance {
+        return Arc::from(background_adjusted);
+    }
+
+    let reordered = maximize_adjacent_separation(&background_adjusted, background);
+    if min_adjacent_oklab_distance(&reordered, background) >= comfortable_distance {
+        Arc::from(reordered)
+    } else {
+        Arc::from(background_adjusted)
+    }
+}
+
+fn maximize_adjacent_separation(accents: &[Hsla], background: Hsla) -> Vec {
+    let Some((&first, rest)) = accents.split_first() else {
+        return Vec::new();
+    };
+    let mut remaining = rest.to_vec();
+    let mut order = Vec::with_capacity(accents.len());
+    order.push(first);
+    let mut last = first;
+
+    while !remaining.is_empty() {
+        let Some((position, &next)) =
+            remaining
+                .iter()
+                .enumerate()
+                .max_by(|&(_, &left), &(_, &right)| {
+                    compare_candidates(background, last, first, left, right)
+                })
+        else {
+            break;
+        };
+        remaining.swap_remove(position);
+        order.push(next);
+        last = next;
+    }
+
+    order
+}
+
+fn compare_candidates(
+    background: Hsla,
+    last: Hsla,
+    first: Hsla,
+    left: Hsla,
+    right: Hsla,
+) -> Ordering {
+    adjacent_distance(last, left, background)
+        .partial_cmp(&adjacent_distance(last, right, background))
+        .unwrap_or(Ordering::Equal)
+        .then_with(|| {
+            adjacent_distance(first, left, background)
+                .partial_cmp(&adjacent_distance(first, right, background))
+                .unwrap_or(Ordering::Equal)
+        })
+}
+
+fn min_adjacent_oklab_distance(accents: &[Hsla], background: Hsla) -> f32 {
+    if accents.len() < 2 {
+        return f32::MAX;
+    }
+    accents
+        .iter()
+        .copied()
+        .zip(accents.iter().copied().cycle().skip(1))
+        .take(accents.len())
+        .map(|(left, right)| adjacent_distance(left, right, background))
+        .fold(f32::MAX, f32::min)
+}
+
+fn oklab_distance(left: Oklab, right: Oklab) -> f32 {
+    let dl = left.l - right.l;
+    let da = left.a - right.a;
+    let db = left.b - right.b;
+    (dl * dl + da * da + db * db).sqrt()
+}
+
+fn adjacent_distance(left: Hsla, right: Hsla, background: Hsla) -> f32 {
+    oklab_distance(
+        hsla_to_oklab(background.blend(left)),
+        hsla_to_oklab(background.blend(right)),
+    )
+}
+
+fn adjust_color_for_background(
+    color: Hsla,
+    background: Hsla,
+    minimum_background_contrast: f32,
+) -> Hsla {
+    if background_contrast(color, background) >= minimum_background_contrast {
+        return color;
+    }
+
+    let original = hsla_to_oklab(color);
+    let darker_candidate = adjusted_lightness_candidate(
+        color,
+        background,
+        minimum_background_contrast,
+        LIGHTNESS_CLAMP_MIN,
+    );
+    let lighter_candidate = adjusted_lightness_candidate(
+        color,
+        background,
+        minimum_background_contrast,
+        LIGHTNESS_CLAMP_MAX,
+    );
+
+    match (darker_candidate, lighter_candidate) {
+        (Some(darker_candidate), Some(lighter_candidate)) => {
+            let darker_distance = oklab_distance(original, hsla_to_oklab(darker_candidate));
+            let lighter_distance = oklab_distance(original, hsla_to_oklab(lighter_candidate));
+            if darker_distance <= lighter_distance {
+                darker_candidate
+            } else {
+                lighter_candidate
+            }
+        }
+        (Some(darker_candidate), None) => darker_candidate,
+        (None, Some(lighter_candidate)) => lighter_candidate,
+        (None, None) => color,
+    }
+}
+
+fn adjusted_lightness_candidate(
+    color: Hsla,
+    background: Hsla,
+    minimum_background_contrast: f32,
+    target_lightness: f32,
+) -> Option {
+    let original = hsla_to_oklch(color);
+    let lightness_delta = target_lightness - original.l;
+
+    if lightness_delta.abs() <= f32::EPSILON {
+        return None;
+    }
+
+    (1..=128).find_map(|step| {
+        let amount = step as f32 / 128.0;
+        let candidate = oklch_to_hsla(
+            Oklch {
+                l: (original.l + lightness_delta * amount).clamp(0.0, 1.0),
+                chroma: original.chroma,
+                hue: original.hue,
+            },
+            color.a,
+        );
+        (background_contrast(candidate, background) >= minimum_background_contrast)
+            .then_some(candidate)
+    })
+}
+
+fn background_contrast(foreground: Hsla, background: Hsla) -> f32 {
+    apca_contrast(background.blend(foreground), background).abs()
+}
+
 fn compute_bracket_ranges(
     multi_buffer_snapshot: &MultiBufferSnapshot,
     buffer_snapshot: &BufferSnapshot,
@@ -201,7 +397,7 @@ mod tests {
     };
     use collections::HashSet;
     use fs::FakeFs;
-    use gpui::UpdateGlobal as _;
+    use gpui::{Rgba, UpdateGlobal as _, hsla};
     use indoc::indoc;
     use itertools::Itertools;
     use language::{Capability, markdown_lang};
@@ -213,10 +409,154 @@ mod tests {
     use serde_json::json;
     use settings::{AccentContent, SettingsStore};
     use text::{Bias, OffsetRangeExt, ToOffset};
+    use theme::Appearance;
     use theme_settings::ThemeStyleContent;
+    use ui::ActiveTheme;
 
     use util::{path, post_inc};
 
+    fn light_editor_background() -> Hsla {
+        hsla(0.0, 0.0, 0.98, 1.0)
+    }
+
+    fn dark_editor_background() -> Hsla {
+        hsla(0.0, 0.0, 0.12, 1.0)
+    }
+
+    #[test]
+    fn test_auto_bracket_colorization_mode_reorders_weak_palette() {
+        let accents = vec![
+            hsla(0.0, 1.0, 0.68, 1.0),
+            hsla(0.02, 1.0, 0.68, 1.0),
+            hsla(0.34, 1.0, 0.68, 1.0),
+            hsla(0.36, 1.0, 0.68, 1.0),
+        ];
+
+        let original_min_adj = min_adjacent_oklab_distance(&accents, dark_editor_background());
+        let reordered = maximize_adjacent_separation(&accents, dark_editor_background());
+        let reordered_min_adj = min_adjacent_oklab_distance(&reordered, dark_editor_background());
+
+        assert_ne!(reordered.as_slice(), accents.as_slice());
+        assert!(reordered_min_adj > original_min_adj);
+    }
+
+    #[test]
+    fn test_preserves_strong_palette() {
+        let accents = vec![
+            hsla(0.0, 1.0, 0.78, 1.0),
+            hsla(0.16, 1.0, 0.78, 1.0),
+            hsla(0.33, 1.0, 0.78, 1.0),
+            hsla(0.66, 1.0, 0.78, 1.0),
+        ];
+
+        let palette =
+            bracket_colorization_accents(&accents, Appearance::Dark, dark_editor_background());
+
+        assert_eq!(palette.as_ref(), accents.as_slice());
+    }
+
+    #[test]
+    fn test_adjusts_background_failures_preserving_hue_and_chroma() {
+        let accents = vec![
+            hsla(0.58, 1.0, 0.28, 1.0),
+            hsla(0.12, 1.0, 0.28, 1.0),
+            hsla(0.22, 0.9, 0.76, 1.0),
+        ];
+
+        let palette =
+            bracket_colorization_accents(&accents, Appearance::Light, light_editor_background());
+        let original = hsla_to_oklch(accents[2]);
+        let adjusted = hsla_to_oklch(palette[2]);
+
+        assert_ne!(palette.as_ref(), accents.as_slice());
+        assert_eq!(palette.len(), accents.len());
+        assert_eq!(palette[0], accents[0]);
+        assert_eq!(palette[1], accents[1]);
+        assert_ne!(palette[2], accents[2]);
+        assert!((original.chroma - adjusted.chroma).abs() < 0.0001);
+        assert!((original.hue - adjusted.hue).abs() < 0.001);
+        assert_ne!(original.l, adjusted.l);
+        assert!(
+            background_contrast(palette[2], light_editor_background()) >= BACKGROUND_APCA_LIGHT
+        );
+    }
+
+    #[test]
+    fn test_preserves_light_near_miss_palette() {
+        let accents = vec![
+            Hsla::from(Rgba::try_from("#CC241D").expect("valid color")),
+            Hsla::from(Rgba::try_from("#98971A").expect("valid color")),
+            Hsla::from(Rgba::try_from("#D79921").expect("valid color")),
+            Hsla::from(Rgba::try_from("#458588").expect("valid color")),
+            Hsla::from(Rgba::try_from("#B16286").expect("valid color")),
+            Hsla::from(Rgba::try_from("#689D6A").expect("valid color")),
+            Hsla::from(Rgba::try_from("#D65D0E").expect("valid color")),
+        ];
+        let background = Hsla::from(Rgba::try_from("#FBF1C7").expect("valid color"));
+
+        let palette = bracket_colorization_accents(&accents, Appearance::Light, background);
+        let original_min_adj = min_adjacent_oklab_distance(&accents, background);
+
+        assert_eq!(palette.as_ref(), accents.as_slice());
+        assert!(original_min_adj < ADJACENT_OKLAB_LIGHT);
+        assert!(original_min_adj >= ADJACENT_OKLAB_LIGHT_INTERVENTION);
+    }
+
+    #[test]
+    fn test_adjust_color_for_background_prefers_closest_passing_candidate() {
+        // Verify that when both darker and lighter candidates exist,
+        // we pick the one with minimum OKLab distance from the original.
+        let background = hsla(0.0, 0.0, 0.50, 1.0);
+        let min_contrast = 30.0;
+        let color = hsla(0.33, 0.7, 0.46, 1.0);
+
+        assert!(
+            background_contrast(color, background) < min_contrast,
+            "test color must fail contrast check; got {}",
+            background_contrast(color, background)
+        );
+
+        let original = hsla_to_oklab(color);
+        let darker =
+            adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MIN)
+                .expect("fixture must produce a darker passing candidate");
+        let lighter =
+            adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MAX)
+                .expect("fixture must produce a lighter passing candidate");
+
+        let darker_dist = oklab_distance(original, hsla_to_oklab(darker));
+        let lighter_dist = oklab_distance(original, hsla_to_oklab(lighter));
+        let expected = if darker_dist <= lighter_dist {
+            darker
+        } else {
+            lighter
+        };
+        assert_eq!(
+            adjust_color_for_background(color, background, min_contrast),
+            expected
+        );
+    }
+
+    #[test]
+    fn test_background_adjustment_edge_cases() {
+        let color = hsla(0.22, 0.9, 0.76, 1.0);
+        let original_contrast = background_contrast(color, light_editor_background());
+        assert!(original_contrast < 20.0);
+        let palette =
+            bracket_colorization_accents(&[color], Appearance::Light, light_editor_background());
+        assert_ne!(palette.as_ref(), &[color][..]);
+        assert!(
+            background_contrast(palette[0], light_editor_background()) >= BACKGROUND_APCA_LIGHT
+        );
+
+        let impossible_color = hsla(0.58, 1.0, 0.47, 1.0);
+        let impossible_bg = hsla(0.0, 0.0, 0.50, 1.0);
+        assert_eq!(
+            adjust_color_for_background(impossible_color, impossible_bg, 200.0),
+            impossible_color
+        );
+    }
+
     #[gpui::test]
     async fn test_basic_bracket_colorization(cx: &mut gpui::TestAppContext) {
         init_test(cx, |language_settings| {
@@ -291,11 +631,11 @@ where
     2
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 6 hsla(95.00, 38.00%, 62.00%, 1.00)
 7 hsla(39.00, 67.00%, 69.00%, 1.00)
 "#,
@@ -327,7 +667,7 @@ where
 
         assert_eq!(
             "fn main«1()1» «1{}1»
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
 ",
             editor
                 .update(cx, |editor, window, cx| {
@@ -356,7 +696,7 @@ where
 
         assert_eq!(
             r#"«1[LLM-powered features]1»«1(./ai/overview.md)1», «1[bring and configure your own API keys]1»«1(./ai/llm-providers.md#use-your-own-keys)1»
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "All markdown brackets should be colored based on their depth"
@@ -368,8 +708,8 @@ where
 
         assert_eq!(
             r#"«1{«2{}2»}1»
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "All markdown brackets should be colored based on their depth, again"
@@ -384,7 +724,7 @@ where
         cx.executor().run_until_parked();
 
         assert_eq!(
-            "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 16.20%, 69.19%, 1.00)\n2 hsla(29.00, 54.00%, 65.88%, 1.00)\n",
+            "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 81.00%, 66.00%, 1.00)\n2 hsla(29.00, 54.00%, 61.00%, 1.00)\n",
             &bracket_colors_markup(&mut cx),
             "Markdown quote pairs should not interfere with parenthesis pairing"
         );
@@ -403,7 +743,7 @@ where
         .await;
 
         let rows = 100;
-        let footer = "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n";
+        let footer = "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n";
 
         let simple_brackets = (0..rows).map(|_| "ˇ[]\n").collect::();
         let simple_brackets_highlights = (0..rows).map(|_| "«1[]1»\n").collect::();
@@ -477,8 +817,8 @@ where
     let v: Vec = vec!«2[]2»;
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "Markdown does not colorize <> brackets"
@@ -495,8 +835,8 @@ where
     let v: Vec«22» = vec!«2[]2»;
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "After switching to Rust, <> brackets are now colorized"
@@ -540,9 +880,9 @@ fn process_data«1()1» «1{
     let map: Result<
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
             "Brackets without pairs should be ignored and not colored"
@@ -563,9 +903,9 @@ fn process_data«1()1» «1{
     let map: Result2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
             "When brackets start to get closed, inner brackets are re-colored based on their depth"
@@ -608,10 +948,10 @@ fn process_data«1()1» «1{
     let map: Result3»>2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
         );
@@ -631,11 +971,11 @@ fn process_data«1()1» «1{
     let map: Result«24»>3», «3()3»>2» = unimplemented!«2()2»;
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
         );
@@ -687,11 +1027,11 @@ mod foo «1{
     }
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -719,11 +1059,11 @@ mod foo «1{
     }2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -750,11 +1090,11 @@ mod foo «1{
     }
     «3{«4{}4»}3»}2»}1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -781,11 +1121,11 @@ mod foo «1{
     }
     «3{«4{}4»}3»}2»}1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -842,11 +1182,11 @@ mod foo «1{
     }
     {{}}}}1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#,
                 comment_lines,
             ),
@@ -1353,11 +1693,11 @@ mod foo «1{
     }2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#,},
             &editor_bracket_colors_markup(&editor_snapshot),
             "Multi buffers should have their brackets colored even if no excerpts contain the bracket counterpart (after fn `process_data_2()`) \
@@ -1393,11 +1733,11 @@ mod foo «1{
     }2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#,},
             &editor_bracket_colors_markup(&editor_snapshot),
         );
@@ -1424,7 +1764,18 @@ mod foo «1{
         let editor_snapshot = editor
             .update(cx, |editor, window, cx| editor.snapshot(window, cx))
             .unwrap();
-        assert_eq!(
+        let adjusted_palette = cx.update(|cx| {
+            bracket_colorization_accents(
+                &[
+                    Hsla::from(Rgba::try_from("#ff0000").expect("valid override accent")),
+                    Hsla::from(Rgba::try_from("#0000ff").expect("valid override accent")),
+                ],
+                cx.theme().appearance,
+                cx.theme().colors().editor_background,
+            )
+        });
+        let expected_markup = format!(
+            "{}\n1 {}\n2 {}\n",
             indoc! {r#"
 
 
@@ -1442,11 +1793,13 @@ mod foo «1{
         let other_map: Option«12»>1» = None;
     }2»
 }1»
-
-1 hsla(0.00, 100.00%, 78.12%, 1.00)
-2 hsla(240.00, 100.00%, 82.81%, 1.00)
 "#,},
-            &editor_bracket_colors_markup(&editor_snapshot),
+            adjusted_palette[0],
+            adjusted_palette[1],
+        );
+        assert_eq!(
+            expected_markup,
+            editor_bracket_colors_markup(&editor_snapshot),
             "After updating theme accents, the editor should update the bracket coloring"
         );
     }
@@ -1536,10 +1889,10 @@ mod foo «1{
                 "    let other_map: Option\u{00ab}23\u{00bb}>2\u{00bb} = None;\n",
                 "}1\u{00bb}\n",
                 "\n",
-                "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n",
-                "2 hsla(29.00, 54.00%, 65.88%, 1.00)\n",
-                "3 hsla(286.00, 51.00%, 75.25%, 1.00)\n",
-                "4 hsla(187.00, 47.00%, 59.22%, 1.00)\n",
+                "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n",
+                "2 hsla(29.00, 54.00%, 61.00%, 1.00)\n",
+                "3 hsla(286.00, 51.00%, 64.00%, 1.00)\n",
+                "4 hsla(187.00, 47.00%, 55.00%, 1.00)\n",
             ),
             &editor_bracket_colors_markup(&editor_snapshot),
             "Two close excerpts from the same buffer (within same tree-sitter chunk) should both have bracket colors"
@@ -1592,9 +1945,9 @@ fn small_function«1()1» «1{
     let x = «2(1, «3(2, 3)3»)2»;
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
 "#,},
             bracket_colors_markup(&mut cx),
         );
diff --git a/crates/editor/src/clipboard.rs b/crates/editor/src/clipboard.rs
index 2d3afdac12b82a..b6875bee48083c 100644
--- a/crates/editor/src/clipboard.rs
+++ b/crates/editor/src/clipboard.rs
@@ -1,4 +1,5 @@
 use super::*;
+use util::rel_path::RelPath;
 
 #[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct ClipboardSelection {
@@ -263,6 +264,63 @@ impl Editor {
         if self.read_only(cx) {
             return;
         }
+
+        let clipboard_image = item.entries().iter().find_map(|entry| match entry {
+            ClipboardEntry::Image(image) if !image.bytes.is_empty() => Some(image),
+            _ => None,
+        });
+
+        if let Some(image) = clipboard_image {
+            let is_markdown = {
+                let display_map = self.display_snapshot(cx);
+                let selections = self.selections.all::(&display_map);
+                selections
+                    .first()
+                    .and_then(|s| display_map.buffer_snapshot().language_at(s.head()))
+                    .map(|lang| lang.name() == "Markdown")
+                    .unwrap_or(false)
+            };
+
+            if is_markdown {
+                let handled = maybe!({
+                    let buffer = self.buffer().read(cx).as_singleton()?;
+                    let file = buffer.read(cx).file()?;
+                    let worktree_id = file.worktree_id(cx);
+                    let dir_rel_path = file.path().parent()?.into_arc();
+                    let worktree = self
+                        .project
+                        .as_ref()?
+                        .read(cx)
+                        .worktree_for_id(worktree_id, cx)?;
+
+                    let extension = image.format.extension();
+                    let snapshot = worktree.read(cx).snapshot();
+                    let (filename, file_path) =
+                        unused_image_path(&dir_rel_path, extension, |path| {
+                            snapshot.entry_for_path(path).is_some()
+                        })?;
+
+                    let create_task = worktree.update(cx, |worktree, cx| {
+                        worktree.create_entry(file_path, false, Some(image.bytes.clone()), cx)
+                    });
+
+                    cx.spawn_in(window, async move |editor, cx| {
+                        create_task.await?;
+                        editor.update_in(cx, |editor, window, cx| {
+                            editor.insert_image_snippet(&filename, window, cx)
+                        })
+                    })
+                    .detach_and_log_err(cx);
+
+                    Some(())
+                });
+                if handled.is_some() {
+                    // stop clipboard handling when the snippet is inserted
+                    return;
+                }
+            }
+        }
+
         let clipboard_string = item.entries().iter().find_map(|entry| match entry {
             ClipboardEntry::String(s) => Some(s),
             _ => None,
@@ -279,6 +337,26 @@ impl Editor {
         }
     }
 
+    fn insert_image_snippet(
+        &mut self,
+        filename: &str,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(snippet) = Snippet::parse(&format!("![$1]({filename})$0")).log_err() else {
+            return;
+        };
+        let display_map = self.display_snapshot(cx);
+        let insertion_ranges: Vec> = self
+            .selections
+            .all::(&display_map)
+            .into_iter()
+            .map(|selection| selection.start..selection.end)
+            .collect();
+        self.insert_snippet(&insertion_ranges, snippet, window, cx)
+            .log_err();
+    }
+
     pub(super) fn cut_common(
         &mut self,
         cut_no_selection_line: bool,
@@ -354,6 +432,22 @@ impl Editor {
         if self.read_only(cx) {
             return;
         }
+        let selection_count = self.selections.count();
+        let first_selection = self.selections.first_anchor();
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let first_selection_is_empty = first_selection.start == first_selection.end;
+        let selection_start_point = first_selection.start.to_point(&snapshot);
+        let selection_start_row = selection_start_point.row;
+        let selection_start_column = selection_start_point.column;
+        let Some((_, text_anchor)) = self
+            .buffer
+            .read(cx)
+            .text_anchor_for_position(first_selection.start, cx)
+        else {
+            return;
+        };
+        let buffer_id = text_anchor.buffer_id;
+
         self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.move_with(&mut |snapshot, sel| {
                 if sel.is_empty() {
@@ -365,7 +459,56 @@ impl Editor {
             });
         });
         let item = self.cut_common(false, window, cx);
-        cx.set_global(KillRing(item))
+
+        let Some(item_text) = item.text() else {
+            return;
+        };
+
+        let entry_metadata = item.entries().first().and_then(|entry| match entry {
+            ClipboardEntry::String(entry) => entry.metadata_json::>(),
+            _ => None,
+        });
+
+        let can_append = selection_count == 1 && first_selection_is_empty;
+        let item = if can_append
+            && let Some(previous_ring) = cx.try_global::()
+            && previous_ring.can_append
+            && previous_ring.buffer_id == buffer_id
+            && previous_ring.row == selection_start_row
+            && previous_ring.column == selection_start_column
+        {
+            let mut entries = previous_ring
+                .metadata
+                .as_ref()
+                .map_or_else(Vec::new, Clone::clone);
+            if let Some(metadata) = entry_metadata.as_ref() {
+                entries.extend_from_slice(metadata);
+            }
+
+            let mut text = previous_ring.text.clone();
+            text.push_str(&item_text);
+            let text_len = text.len();
+
+            KillRing {
+                text,
+                metadata: kill_ring_metadata_for_text(entries, text_len),
+                row: previous_ring.row,
+                column: previous_ring.column,
+                buffer_id: previous_ring.buffer_id,
+                can_append,
+            }
+        } else {
+            KillRing {
+                text: item_text,
+                metadata: entry_metadata,
+                row: selection_start_row,
+                column: selection_start_column,
+                buffer_id,
+                can_append,
+            }
+        };
+
+        cx.set_global(item)
     }
 
     pub(super) fn kill_ring_yank(
@@ -374,15 +517,15 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        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 {
+        if !cx.has_global::() {
             return;
-        };
+        }
+
+        let (text, metadata) = cx.update_global::(|kill_ring, _| {
+            kill_ring.can_append = false;
+            (kill_ring.text.clone(), kill_ring.metadata.clone())
+        });
+
         self.do_paste(&text, metadata, false, window, cx);
     }
 
@@ -536,9 +679,36 @@ impl Editor {
     }
 }
 
-struct KillRing(ClipboardItem);
+struct KillRing {
+    text: String,
+    metadata: Option>,
+    row: u32,
+    column: u32,
+    buffer_id: BufferId,
+    can_append: bool,
+}
 impl Global for KillRing {}
 
+fn kill_ring_metadata_for_text(
+    mut metadata: Vec,
+    text_len: usize,
+) -> Option> {
+    match metadata.len() {
+        0 => None,
+        1 => Some(metadata),
+        _ => {
+            let first_selection = metadata.remove(0);
+            Some(vec![ClipboardSelection {
+                len: text_len,
+                is_entire_line: false,
+                first_line_indent: first_selection.first_line_indent,
+                file_path: None,
+                line_range: None,
+            }])
+        }
+    }
+}
+
 fn edit_for_markdown_paste<'a>(
     buffer: &MultiBufferSnapshot,
     range: Range,
@@ -559,6 +729,26 @@ fn edit_for_markdown_paste<'a>(
     (range, new_text)
 }
 
+/// Returns a filename of the form `image.{extension}` (or `image_{N}.{extension}`
+/// if taken) that does not collide with an existing entry in `dir_rel_path`,
+/// along with the full path of the candidate file.
+fn unused_image_path(
+    dir_rel_path: &RelPath,
+    extension: &str,
+    exists: impl Fn(&RelPath) -> bool,
+) -> Option<(String, Arc)> {
+    let mut filename = format!("image.{extension}");
+    let mut counter = 1u32;
+    loop {
+        let candidate = dir_rel_path.join(RelPath::from_unix_str(&filename).ok()?);
+        if !exists(&candidate) {
+            return Some((filename, candidate.into()));
+        }
+        filename = format!("image_{counter}.{extension}");
+        counter += 1;
+    }
+}
+
 /// Whether `text` consists solely of a single URL, as opposed to merely
 /// starting with a scheme-like prefix (e.g. a commit message like
 /// `editor: Fix ...`, which `url::Url::parse` would accept).
diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs
index 25ac00a495c82b..b192fa3d683bb5 100644
--- a/crates/editor/src/code_context_menus.rs
+++ b/crates/editor/src/code_context_menus.rs
@@ -1,9 +1,9 @@
 use crate::scroll::ScrollAmount;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollHandle, ScrollStrategy,
-    SharedString, Size, StrikethroughStyle, StyledText, Task, TaskExt, UniformListScrollHandle,
-    div, px, uniform_list,
+    AnyElement, Entity, Focusable, FontWeight, HighlightStyle, ListSizingBehavior, ScrollHandle,
+    ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText, Task, TaskExt,
+    UniformListScrollHandle, div, px, uniform_list,
 };
 use itertools::Itertools;
 use language::CodeLabel;
@@ -1006,43 +1006,18 @@ impl CompletionsMenu {
 
                         let highlights: Vec<_> = highlights.collect();
 
-                        let filter_range = &completion.label.filter_range;
-                        let full_text = &completion.label.text;
-
-                        let main_text: String = full_text[filter_range.clone()].to_string();
-                        let main_highlights: Vec<_> = highlights
-                            .iter()
-                            .filter_map(|(range, highlight)| {
-                                if range.end <= filter_range.start
-                                    || range.start >= filter_range.end
-                                {
-                                    return None;
-                                }
-                                let clamped_start =
-                                    range.start.max(filter_range.start) - filter_range.start;
-                                let clamped_end =
-                                    range.end.min(filter_range.end) - filter_range.start;
-                                Some((clamped_start..clamped_end, (*highlight)))
-                            })
-                            .collect();
-                        let main_label = StyledText::new(main_text)
+                        let ((main_text, main_highlights), (suffix_text, suffix_highlights)) =
+                            split_completion_label(
+                                &completion.label.text,
+                                &completion.label.filter_range,
+                                &highlights,
+                            );
+                        let main_label = StyledText::new(main_text.to_string())
                             .with_default_highlights(&style.text, main_highlights);
 
-                        let suffix_text: String = full_text[filter_range.end..].to_string();
-                        let suffix_highlights: Vec<_> = highlights
-                            .iter()
-                            .filter_map(|(range, highlight)| {
-                                if range.end <= filter_range.end {
-                                    return None;
-                                }
-                                let shifted_start = range.start.saturating_sub(filter_range.end);
-                                let shifted_end = range.end - filter_range.end;
-                                Some((shifted_start..shifted_end, (*highlight)))
-                            })
-                            .collect();
                         let suffix_label = if !suffix_text.is_empty() {
                             Some(
-                                StyledText::new(suffix_text)
+                                StyledText::new(suffix_text.to_string())
                                     .with_default_highlights(&style.text, suffix_highlights),
                             )
                         } else {
@@ -1691,6 +1666,42 @@ fn completion_kind_highlight_name(kind: CompletionItemKind) -> Option<&'static s
     })
 }
 
+fn split_completion_label<'a>(
+    text: &'a str,
+    filter_range: &Range,
+    highlights: &[(Range, HighlightStyle)],
+) -> (
+    (&'a str, Vec<(Range, HighlightStyle)>),
+    (&'a str, Vec<(Range, HighlightStyle)>),
+) {
+    let (main_text, suffix_text) = text.split_at(filter_range.end);
+    let main_highlights = highlights
+        .iter()
+        .filter_map(|(range, highlight)| {
+            if range.start >= filter_range.end {
+                return None;
+            }
+            let clamped_end = range.end.min(filter_range.end);
+            Some((range.start..clamped_end, *highlight))
+        })
+        .collect();
+    let suffix_highlights = highlights
+        .iter()
+        .filter_map(|(range, highlight)| {
+            if range.end <= filter_range.end {
+                return None;
+            }
+            let shifted_start = range.start.saturating_sub(filter_range.end);
+            let shifted_end = range.end - filter_range.end;
+            Some((shifted_start..shifted_end, *highlight))
+        })
+        .collect();
+    (
+        (main_text, main_highlights),
+        (suffix_text, suffix_highlights),
+    )
+}
+
 fn exact_case_match_count(query: &str, string_match: &StringMatch) -> usize {
     let mut exact_matches = 0;
     let mut query_chars = query.chars();
@@ -2038,3 +2049,45 @@ impl CodeActionsMenu {
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn bold() -> HighlightStyle {
+        FontWeight::BOLD.into()
+    }
+
+    fn colored() -> HighlightStyle {
+        HighlightStyle {
+            fade_out: Some(0.5),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn test_split_completion_label_keeps_prefix_before_filter_range() {
+        let ((main_text, main_highlights), (suffix_text, suffix_highlights)) =
+            split_completion_label("&some_str: String", &(1..9), &[(11..17, colored())]);
+
+        assert_eq!(main_text, "&some_str");
+        assert_eq!(suffix_text, ": String");
+        assert_eq!(main_highlights, vec![]);
+        assert_eq!(suffix_highlights, vec![(2..8, colored())]);
+    }
+
+    #[test]
+    fn test_split_completion_label_splits_boundary_spanning_highlight() {
+        let ((main_text, main_highlights), (suffix_text, suffix_highlights)) =
+            split_completion_label(
+                "await.as_deref_mut(&mut self)",
+                &(6..18),
+                &[(0..29, bold())],
+            );
+
+        assert_eq!(main_text, "await.as_deref_mut");
+        assert_eq!(suffix_text, "(&mut self)");
+        assert_eq!(main_highlights, vec![(0..18, bold())]);
+        assert_eq!(suffix_highlights, vec![(0..11, bold())]);
+    }
+}
diff --git a/crates/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs
index 3d262788af050e..8c04f1977a42bb 100644
--- a/crates/editor/src/code_lens.rs
+++ b/crates/editor/src/code_lens.rs
@@ -1643,6 +1643,7 @@ mod tests {
     #[gpui::test]
     async fn test_code_lens_resolve_only_visible(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
+        crate::editor_tests::pin_upstream_buffer_font_metrics(cx);
         update_test_editor_settings(cx, &|settings| {
             settings.code_lens = Some(CodeLens::On);
         });
diff --git a/crates/editor/src/completions.rs b/crates/editor/src/completions.rs
index e2f0be07ce0968..8ad37dcfd63ec4 100644
--- a/crates/editor/src/completions.rs
+++ b/crates/editor/src/completions.rs
@@ -830,14 +830,19 @@ impl Editor {
         let old_text = buffer
             .text_for_range(replace_range.clone())
             .collect::();
-        let lookbehind = newest_range_buffer
-            .start
-            .to_offset(buffer_snapshot)
-            .saturating_sub(replace_range.start.to_offset(&buffer_snapshot));
-        let lookahead = replace_range
-            .end
-            .to_offset(&buffer_snapshot)
-            .saturating_sub(newest_range_buffer.end.to_offset(&buffer));
+        let (lookbehind, lookahead) = if buffer.remote_id() == buffer_snapshot.remote_id() {
+            let lookbehind = newest_range_buffer
+                .start
+                .to_offset(&buffer)
+                .saturating_sub(replace_range.start.to_offset(&buffer));
+            let lookahead = replace_range
+                .end
+                .to_offset(&buffer)
+                .saturating_sub(newest_range_buffer.end.to_offset(&buffer));
+            (lookbehind, lookahead)
+        } else {
+            (0, 0)
+        };
         let prefix = &old_text[..old_text.len().saturating_sub(lookahead)];
         let suffix = &old_text[lookbehind.min(old_text.len())..];
 
diff --git a/crates/editor/src/config.rs b/crates/editor/src/config.rs
index 9b5df0b86713b2..bfda1aae44e4ec 100644
--- a/crates/editor/src/config.rs
+++ b/crates/editor/src/config.rs
@@ -360,10 +360,6 @@ impl Editor {
         self.delegate_expand_excerpts = delegate;
     }
 
-    pub(super) fn set_delegate_stage_and_restore(&mut self, delegate: bool) {
-        self.delegate_stage_and_restore = delegate;
-    }
-
     pub(super) fn set_on_local_selections_changed(
         &mut self,
         callback: Option) + 'static>>,
diff --git a/crates/editor/src/diagnostics.rs b/crates/editor/src/diagnostics.rs
index f3e3137b3fa9a6..a1e40bdb392599 100644
--- a/crates/editor/src/diagnostics.rs
+++ b/crates/editor/src/diagnostics.rs
@@ -331,13 +331,12 @@ impl Editor {
         self.set_max_diagnostics_severity(new_severity, cx);
         if self.diagnostics_enabled {
             self.active_diagnostics = ActiveDiagnostic::None;
+            self.refresh_inline_diagnostics(false, window, cx);
+        } else {
             self.inline_diagnostics_update = Task::ready(());
             self.inline_diagnostics.clear();
-        } else {
-            self.refresh_inline_diagnostics(false, window, cx);
+            cx.notify();
         }
-
-        cx.notify();
     }
 
     pub(super) fn all_diagnostics_active(&self) -> bool {
@@ -464,6 +463,7 @@ impl Editor {
         {
             self.inline_diagnostics_update = Task::ready(());
             self.inline_diagnostics.clear();
+            cx.notify();
             return;
         }
 
@@ -596,3 +596,158 @@ impl Editor {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::{
+        actions::{ToggleDiagnostics, ToggleInlineDiagnostics},
+        editor_tests::init_test,
+        test::editor_test_context::EditorTestContext,
+    };
+    use gpui::{TestAppContext, UpdateGlobal};
+    use indoc::indoc;
+    use language::DiagnosticSourceKind;
+    use lsp::LanguageServerId;
+    use settings::{DelayMs, SettingsStore};
+    use std::sync::{
+        Arc,
+        atomic::{self, AtomicUsize},
+    };
+    use util::path;
+
+    fn setup_inline_diagnostics(cx: &mut EditorTestContext) {
+        cx.update(|_, cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    let inline = settings
+                        .diagnostics
+                        .get_or_insert_default()
+                        .inline
+                        .get_or_insert_default();
+                    inline.enabled = Some(true);
+                    inline.update_debounce_ms = Some(DelayMs(0));
+                });
+            });
+        });
+
+        cx.set_state(indoc! {"
+            fn func(abc dˇef: i32) -> u32 {
+            }
+        "});
+
+        let lsp_store =
+            cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store());
+        cx.update(|_, cx| {
+            lsp_store.update(cx, |lsp_store, cx| {
+                lsp_store
+                    .update_diagnostics(
+                        LanguageServerId(0),
+                        lsp::PublishDiagnosticsParams {
+                            uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(),
+                            version: None,
+                            diagnostics: vec![lsp::Diagnostic {
+                                range: lsp::Range::new(
+                                    lsp::Position::new(0, 12),
+                                    lsp::Position::new(0, 15),
+                                ),
+                                severity: Some(lsp::DiagnosticSeverity::ERROR),
+                                message: "cannot find value `def`".to_string(),
+                                ..Default::default()
+                            }],
+                        },
+                        None,
+                        DiagnosticSourceKind::Pushed,
+                        &[],
+                        cx,
+                    )
+                    .unwrap()
+            });
+        });
+        cx.run_until_parked();
+
+        cx.update_editor(|editor, _, _| {
+            assert_eq!(
+                editor.inline_diagnostics.len(),
+                1,
+                "inline diagnostics should appear after the language server publishes them"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_toggle_diagnostics_refreshes_inline_diagnostics(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorTestContext::new(cx).await;
+        setup_inline_diagnostics(&mut cx);
+
+        cx.update_editor(|editor, window, cx| {
+            editor.toggle_diagnostics(&ToggleDiagnostics, window, cx);
+        });
+        cx.run_until_parked();
+        cx.update_editor(|editor, _, _| {
+            assert_eq!(
+                editor.inline_diagnostics.len(),
+                0,
+                "inline diagnostics should be cleared after disabling diagnostics"
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.toggle_diagnostics(&ToggleDiagnostics, window, cx);
+        });
+        cx.run_until_parked();
+        cx.update_editor(|editor, _, _| {
+            assert_eq!(
+                editor.inline_diagnostics.len(),
+                1,
+                "inline diagnostics should reappear after re-enabling diagnostics, without further editor events"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_toggle_inline_diagnostics_notifies_on_hide(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorTestContext::new(cx).await;
+        setup_inline_diagnostics(&mut cx);
+
+        let notify_count = Arc::new(AtomicUsize::new(0));
+        let editor = cx.editor.clone();
+        let _subscription = cx.update({
+            let notify_count = notify_count.clone();
+            move |_, cx| {
+                cx.observe(&editor, move |_, _| {
+                    notify_count.fetch_add(1, atomic::Ordering::SeqCst);
+                })
+            }
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.toggle_inline_diagnostics(&ToggleInlineDiagnostics, window, cx);
+        });
+        cx.update_editor(|editor, _, _| {
+            assert_eq!(
+                editor.inline_diagnostics.len(),
+                0,
+                "inline diagnostics should be cleared after toggling them off"
+            );
+        });
+        assert_eq!(
+            notify_count.load(atomic::Ordering::SeqCst),
+            1,
+            "toggling inline diagnostics off should notify to repaint the editor"
+        );
+
+        cx.update_editor(|editor, window, cx| {
+            editor.toggle_inline_diagnostics(&ToggleInlineDiagnostics, window, cx);
+        });
+        cx.run_until_parked();
+        cx.update_editor(|editor, _, _| {
+            assert_eq!(
+                editor.inline_diagnostics.len(),
+                1,
+                "inline diagnostics should reappear after toggling them back on"
+            );
+        });
+    }
+}
diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs
index 9093fcfecf622f..554fe1a5ab22ae 100644
--- a/crates/editor/src/display_map.rs
+++ b/crates/editor/src/display_map.rs
@@ -1680,6 +1680,36 @@ impl DisplaySnapshot {
         self.display_point_converter().map(range)
     }
 
+    /// Converts a non-empty buffer range into one contiguous display range.
+    /// Inlays at either boundary are excluded, while inlays between selected
+    /// buffer characters are included.
+    pub fn contiguous_display_point_range_for_buffer_range(
+        &self,
+        range: Range,
+    ) -> Option> {
+        if range.is_empty() {
+            return None;
+        }
+
+        let buffer = self.buffer_snapshot();
+        let first_character_end =
+            buffer.clip_offset((range.start + 1usize).min(range.end), Bias::Right);
+        let last_character_start = buffer.clip_offset(
+            range.end.saturating_sub_usize(1).max(range.start),
+            Bias::Left,
+        );
+
+        let mut converter = self.display_point_converter();
+        let first_ranges = converter.map(range.start..first_character_end);
+        let start = first_ranges.first()?.start;
+        if first_character_end == range.end {
+            return Some(start..first_ranges.last()?.end);
+        }
+
+        let last_ranges = converter.map(last_character_start..range.end);
+        Some(start..last_ranges.last()?.end)
+    }
+
     /// Returns a converter that maps buffer offset ranges to `DisplayPoint`
     /// ranges (as in [`Self::isomorphic_display_point_ranges_for_buffer_range`])
     /// while reusing cursor state across calls. Use this when converting many
@@ -4175,6 +4205,62 @@ pub mod tests {
         assert_eq!(ranges.len(), 1);
         assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 10));
         assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 14));
+
+        map.update(cx, |map, cx| {
+            map.splice_inlays(
+                &[InlayId::Hint(0)],
+                vec![
+                    Inlay::mock_hint(1, buffer_snapshot.anchor_after(MultiBufferOffset(5)), "L"),
+                    Inlay::mock_hint(2, buffer_snapshot.anchor_before(MultiBufferOffset(5)), "R"),
+                    Inlay::mock_hint(3, buffer_snapshot.anchor_after(MultiBufferOffset(7)), "I"),
+                ],
+                cx,
+            );
+        });
+        let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
+
+        assert_eq!(
+            snapshot.contiguous_display_point_range_for_buffer_range(
+                MultiBufferOffset(4)..MultiBufferOffset(5),
+            ),
+            Some(DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 5)),
+        );
+        assert_eq!(
+            snapshot.contiguous_display_point_range_for_buffer_range(
+                MultiBufferOffset(5)..MultiBufferOffset(6),
+            ),
+            Some(DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 8)),
+        );
+        assert_eq!(
+            snapshot.contiguous_display_point_range_for_buffer_range(
+                MultiBufferOffset(4)..MultiBufferOffset(6),
+            ),
+            Some(DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 8)),
+        );
+        assert_eq!(
+            snapshot.contiguous_display_point_range_for_buffer_range(
+                MultiBufferOffset(6)..MultiBufferOffset(7),
+            ),
+            Some(DisplayPoint::new(DisplayRow(0), 8)..DisplayPoint::new(DisplayRow(0), 9)),
+        );
+        assert_eq!(
+            snapshot.contiguous_display_point_range_for_buffer_range(
+                MultiBufferOffset(7)..MultiBufferOffset(8),
+            ),
+            Some(DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 11)),
+        );
+        assert_eq!(
+            snapshot.contiguous_display_point_range_for_buffer_range(
+                MultiBufferOffset(4)..MultiBufferOffset(9),
+            ),
+            Some(DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 12)),
+        );
+        assert_eq!(
+            snapshot.contiguous_display_point_range_for_buffer_range(
+                MultiBufferOffset(5)..MultiBufferOffset(5),
+            ),
+            None,
+        );
     }
 
     #[test]
diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs
index c8e1a4424b1156..1a7b9d27d0d448 100644
--- a/crates/editor/src/display_map/block_map.rs
+++ b/crates/editor/src/display_map/block_map.rs
@@ -963,9 +963,14 @@ impl BlockMap {
             // Ensure the edit starts at a transform boundary.
             // If the edit starts within an isomorphic transform, preserve its prefix
             // If the edit lands within a replacement block, expand the edit to include the start of the replaced input range
-            let transform = cursor.item().unwrap();
+            // The cursor can sit past the end of the tree when a companion edit
+            // is anchored at the new end-of-file and maps to `old_start` at the
+            // very end of the old transforms; in that case there is no transform
+            // preceding the edit and nothing to preserve.
             let transform_rows_before_edit = old_start - *cursor.start();
-            if transform_rows_before_edit > RowDelta(0) {
+            if transform_rows_before_edit > RowDelta(0)
+                && let Some(transform) = cursor.item()
+            {
                 if transform.block.is_none() {
                     // Preserve any portion of the old isomorphic transform that precedes this edit.
                     push_isomorphic(
@@ -5019,6 +5024,61 @@ mod tests {
         );
     }
 
+    // Regression test for ZED-9V4: `BlockMap::sync` walks the (old) transform
+    // tree with a `WrapRow` cursor and used to `cursor.item().unwrap()` for
+    // every edit, assuming each edit's `old.start` lands strictly inside the
+    // tree. The companion (split-diff) branch of `sync` can compose an edit
+    // anchored at the trailing boundary of the old transforms
+    // (`old.start == input_rows`), at which point the cursor is past the end of
+    // the tree and `item()` is `None`. That used to abort the process.
+    #[gpui::test]
+    fn test_sync_edit_anchored_at_end_of_transforms(cx: &mut gpui::TestAppContext) {
+        cx.update(init_test);
+
+        let buffer = cx.update(|cx| MultiBuffer::build_simple("aaa\nbbb\nccc\n", cx));
+        let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx));
+        let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
+
+        let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
+        let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
+        let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
+        let (wrap_map, old_wrap_snapshot) =
+            cx.update(|cx| WrapMap::new(tab_snapshot, test_font(), px(14.0), None, cx));
+        let block_map = BlockMap::new(old_wrap_snapshot.clone(), 0, 0);
+
+        // The tree now spans exactly `old_end` input rows.
+        let old_end = old_wrap_snapshot.max_point().row() + RowDelta(1);
+
+        // Grow the buffer so the new snapshot is larger than the old transforms.
+        let buffer_snapshot = buffer.update(cx, |buffer, cx| {
+            buffer.edit(
+                [(Point::new(3, 0)..Point::new(3, 0), "ddd\neee\n")],
+                None,
+                cx,
+            );
+            buffer.snapshot(cx)
+        });
+        let (inlay_snapshot, inlay_edits) =
+            inlay_map.sync(buffer_snapshot, subscription.consume().into_inner());
+        let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
+        let (tab_snapshot, tab_edits) =
+            tab_map.sync(fold_snapshot, fold_edits, 4.try_into().unwrap());
+        let (new_wrap_snapshot, _) = wrap_map.update(cx, |wrap_map, cx| {
+            wrap_map.sync(tab_snapshot, tab_edits, cx)
+        });
+        let new_end = new_wrap_snapshot.max_point().row() + RowDelta(1);
+
+        // An edit anchored exactly at the end of the old transforms, of the shape
+        // the companion branch of `sync` can produce.
+        let edits = Patch::new(vec![text::Edit {
+            old: old_end..old_end,
+            new: old_end..new_end,
+        }]);
+
+        let snapshot = block_map.read(new_wrap_snapshot, edits, None);
+        assert_eq!(snapshot.snapshot.text(), "aaa\nbbb\nccc\nddd\neee\n");
+    }
+
     fn init_test(cx: &mut gpui::App) {
         let settings = SettingsStore::test(cx);
         cx.set_global(settings);
diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs
index 58483e6dab282d..2a2139f6407c3d 100644
--- a/crates/editor/src/document_symbols.rs
+++ b/crates/editor/src/document_symbols.rs
@@ -342,7 +342,7 @@ mod tests {
 
     use crate::{
         Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
-        editor_tests::{init_test, update_test_language_settings},
+        editor_tests::{init_test, update_test_editor_settings, update_test_language_settings},
         test::editor_lsp_test_context::EditorLspTestContext,
     };
 
@@ -901,6 +901,12 @@ mod tests {
         use ui::ActiveTheme as _;
 
         init_test(cx, |_| {});
+        update_test_editor_settings(cx, &|settings| {
+            settings
+                .toolbar
+                .get_or_insert_default()
+                .show_breadcrumb_symbols = Some(true);
+        });
 
         let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
 
diff --git a/crates/editor/src/edit_prediction.rs b/crates/editor/src/edit_prediction.rs
index 92f1ce851384ae..ae79928a449ca4 100644
--- a/crates/editor/src/edit_prediction.rs
+++ b/crates/editor/src/edit_prediction.rs
@@ -152,6 +152,7 @@ impl Editor {
     pub fn set_edit_prediction_provider(
         &mut self,
         provider: Option>,
+        trigger: EditPredictionRequestTrigger,
         window: &mut Window,
         cx: &mut Context,
     ) where
@@ -166,13 +167,7 @@ impl Editor {
             provider: Arc::new(provider),
         });
         self.update_edit_prediction_settings(cx);
-        self.refresh_edit_prediction(
-            false,
-            false,
-            EditPredictionRequestTrigger::Other,
-            window,
-            cx,
-        );
+        self.refresh_edit_prediction(false, false, trigger, window, cx);
     }
 
     pub fn set_edit_predictions_hidden_for_vim_mode(
@@ -189,7 +184,7 @@ impl Editor {
                 self.refresh_edit_prediction(
                     true,
                     false,
-                    EditPredictionRequestTrigger::Other,
+                    EditPredictionRequestTrigger::VimModeChanged,
                     window,
                     cx,
                 );
@@ -1660,13 +1655,16 @@ impl Editor {
     ) -> Task> {
         workspace.update(cx, |workspace, cx| {
             let path = snapshot.file().map(|file| file.full_path(cx));
-            let Some(path) =
-                path.and_then(|path| workspace.project().read(cx).find_project_path(path, cx))
+            let Some(project_path) = path
+                .as_ref()
+                .and_then(|path| workspace.project().read(cx).find_project_path(path, cx))
             else {
-                return Task::ready(Err(anyhow::anyhow!("Project path not found")));
+                return Task::ready(Err(anyhow::anyhow!(
+                    "project path not found for edit prediction target {path:?}"
+                )));
             };
             let target = text::ToPoint::to_point(&target, snapshot);
-            let item = workspace.open_path(path, None, true, window, cx);
+            let item = workspace.open_path(project_path, None, true, window, cx);
             window.spawn(cx, async move |cx| {
                 let Some(editor) = item.await?.downcast::() else {
                     return Ok(());
@@ -2290,7 +2288,7 @@ impl Editor {
         let file_name = snapshot
             .file()
             .map(|file| SharedString::new(file.file_name(cx)))
-            .unwrap_or(SharedString::new_static("untitled"));
+            .unwrap_or(SharedString::new_static(MultiBuffer::DEFAULT_TITLE));
 
         h_flex()
             .id("ep-jump-outside-popover")
@@ -2424,7 +2422,7 @@ impl Editor {
                 let file_name = snapshot
                     .file()
                     .map(|file| file.file_name(cx))
-                    .unwrap_or("untitled");
+                    .unwrap_or(MultiBuffer::DEFAULT_TITLE);
                 Some(
                     h_flex()
                         .px_2()
diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs
index 24ac960b916b81..21922ce7ae1379 100644
--- a/crates/editor/src/edit_prediction_tests.rs
+++ b/crates/editor/src/edit_prediction_tests.rs
@@ -773,7 +773,12 @@ async fn test_edit_prediction_preview_does_not_hide_code_actions_on_modifier_pre
 
     let provider = cx.new(|_| FakeEditPredictionDelegate::default());
     cx.update_editor(|editor, window, cx| {
-        editor.set_edit_prediction_provider(Some(provider.clone()), window, cx);
+        editor.set_edit_prediction_provider(
+            Some(provider.clone()),
+            EditPredictionRequestTrigger::EditorCreated,
+            window,
+            cx,
+        );
     });
 
     let snapshot = cx.buffer_snapshot();
@@ -1634,7 +1639,12 @@ fn assign_editor_completion_provider(
     cx: &mut EditorTestContext,
 ) {
     cx.update_editor(|editor, window, cx| {
-        editor.set_edit_prediction_provider(Some(provider), window, cx);
+        editor.set_edit_prediction_provider(
+            Some(provider),
+            EditPredictionRequestTrigger::EditorCreated,
+            window,
+            cx,
+        );
     })
 }
 
@@ -1672,7 +1682,12 @@ fn assign_editor_completion_provider_non_zed(
     cx: &mut EditorTestContext,
 ) {
     cx.update_editor(|editor, window, cx| {
-        editor.set_edit_prediction_provider(Some(provider), window, cx);
+        editor.set_edit_prediction_provider(
+            Some(provider),
+            EditPredictionRequestTrigger::EditorCreated,
+            window,
+            cx,
+        );
     })
 }
 
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 62f351463c945e..6011c9cf11df8c 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -98,21 +98,25 @@ pub use edit_prediction_types::Direction;
 pub use edit_prediction_types::EditPredictionRequestTrigger;
 pub use editor_settings::{
     CompletionDetailAlignment, CompletionMenuItemKind, CurrentLineHighlight, DiffViewStyle,
-    DocumentColorsRenderMode, EditorSettings, EditorSettingsScrollbarProxy, ScrollBeyondLastLine,
-    ScrollbarAxes, SearchSettings, ShowMinimap, ui_scrollbar_settings_from_raw,
+    DocumentColorsRenderMode, EditorSettings, EditorSettingsScrollbarProxy, OpenResultsIn,
+    ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap,
+    ui_scrollbar_settings_from_raw,
 };
 pub use element::{
     CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
-    render_breadcrumb_text,
+    file_status_label_color, render_breadcrumb_text,
 };
 pub use git::blame::BlameRenderer;
+pub use git::{
+    DiffHunkDelegate, ResolvedDiffHunk, ResolvedDiffHunks, RestoreOnlyDiffHunkDelegate,
+    RestoreOnlyUnstagedDiffHunkDelegate, UncommittedDiffHunkDelegate, render_diff_hunk_controls,
+    set_blame_renderer,
+};
 pub(crate) use git::{DiffHunkKey, StoredReviewComment};
 use git::{
-    DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, render_diff_hunk_controls,
-    update_uncommitted_diff_for_buffer,
+    DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, update_uncommitted_diff_for_buffer,
 };
 pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator};
-pub use git::{RenderDiffHunkControlsFn, set_blame_renderer};
 pub use hover_popover::hover_markdown_style;
 pub use inlays::Inlay;
 pub use items::MAX_TAB_TITLE_LEN;
@@ -124,7 +128,7 @@ pub use multi_buffer::{
     MultiBufferOffset, MultiBufferOffsetUtf16, MultiBufferSnapshot, PathKey, RowInfo, ToOffset,
     ToPoint,
 };
-pub use split::{SplittableEditor, ToggleSplitDiff};
+pub use split::{DiffStyleControls, SplittableEditor, ToggleSplitDiff};
 pub use split_editor_view::SplitEditorView;
 pub use text::Bias;
 
@@ -254,9 +258,8 @@ use theme::{
 };
 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,
-    utils::WithRemSize,
+    Avatar, ContextMenu, Disclosure, IconButtonShape, Indicator, Key, KeyBinding, Tooltip,
+    prelude::*, scrollbars::ScrollbarAutoHide, tooltip_container, utils::WithRemSize,
 };
 use ui_input::ErasedEditor;
 use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
@@ -974,7 +977,6 @@ pub struct Editor {
     offset_content: bool,
     disable_expand_excerpt_buttons: bool,
     delegate_expand_excerpts: bool,
-    delegate_stage_and_restore: bool,
     delegate_open_excerpts: bool,
     enable_lsp_data: bool,
     needs_initial_data_update: bool,
@@ -1075,11 +1077,11 @@ pub struct Editor {
     show_git_blame_inline: bool,
     show_git_blame_inline_delay_task: Option>,
     git_blame_inline_enabled: bool,
-    render_diff_hunk_controls: RenderDiffHunkControlsFn,
     buffer_serialization: Option,
     show_selection_menu: Option,
     blame: Option>,
     blame_subscription: Option,
+    pending_blame_hover_observation: Option,
     custom_context_menu: Option<
         Box<
             dyn 'static
@@ -1129,12 +1131,7 @@ pub struct Editor {
     addons: TypeIdHashMap>,
     registered_buffers: HashMap,
     load_diff_task: Option>>,
-    /// Whether we are temporarily displaying a diff other than git's
-    temporary_diff_override: bool,
-    /// Whether to render all diff hunks with the "unstaged" appearance,
-    /// regardless of whether they have a secondary hunk. Used by views whose
-    /// diffs aren't related to the git index (e.g. agent diffs).
-    render_diff_hunks_as_unstaged: bool,
+    diff_hunk_delegate: Option>,
     selection_mark_mode: bool,
     toggle_fold_multiple_buffers: Task<()>,
     _scroll_cursor_center_top_bottom_task: Task<()>,
@@ -1387,11 +1384,15 @@ struct DeferredSelectionEffectsState {
     history_entry: SelectionHistoryEntry,
 }
 
+#[derive(Clone, Debug)]
+pub struct TransactionSelections {
+    pub undo: Arc<[Selection]>,
+    pub redo: Option]>>,
+}
+
 #[derive(Default)]
 struct SelectionHistory {
-    #[allow(clippy::type_complexity)]
-    selections_by_transaction:
-        HashMap]>, Option]>>)>,
+    selections_by_transaction: HashMap,
     mode: SelectionHistoryMode,
     undo_stack: VecDeque,
     redo_stack: VecDeque,
@@ -1411,23 +1412,23 @@ impl SelectionHistory {
             );
             return;
         }
-        self.selections_by_transaction
-            .insert(transaction_id, (selections, None));
+        self.selections_by_transaction.insert(
+            transaction_id,
+            TransactionSelections {
+                undo: selections,
+                redo: None,
+            },
+        );
     }
 
-    #[allow(clippy::type_complexity)]
-    fn transaction(
-        &self,
-        transaction_id: TransactionId,
-    ) -> Option<&(Arc<[Selection]>, Option]>>)> {
+    fn transaction(&self, transaction_id: TransactionId) -> Option<&TransactionSelections> {
         self.selections_by_transaction.get(&transaction_id)
     }
 
-    #[allow(clippy::type_complexity)]
     fn transaction_mut(
         &mut self,
         transaction_id: TransactionId,
-    ) -> Option<&mut (Arc<[Selection]>, Option]>>)> {
+    ) -> Option<&mut TransactionSelections> {
         self.selections_by_transaction.get_mut(&transaction_id)
     }
 
@@ -1633,6 +1634,97 @@ pub struct RewrapOptions {
     pub line_length: Option,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum GutterButtonIntent {
+    SetBookmark,
+    SetBreakpoint,
+}
+
+impl GutterButtonIntent {
+    fn as_str(&self) -> &'static str {
+        match self {
+            Self::SetBookmark => "Set Bookmark",
+            Self::SetBreakpoint => "Set Breakpoint",
+        }
+    }
+
+    fn icon(&self) -> ui::IconName {
+        match self {
+            Self::SetBookmark => ui::IconName::Bookmark,
+            Self::SetBreakpoint => ui::IconName::DebugBreakpoint,
+        }
+    }
+
+    fn color(&self) -> Color {
+        match self {
+            Self::SetBookmark => Color::Info,
+            Self::SetBreakpoint => Color::Hint,
+        }
+    }
+
+    fn action(&self) -> &'static dyn Action {
+        match self {
+            Self::SetBookmark => &ToggleBookmark,
+            Self::SetBreakpoint => &ToggleBreakpoint,
+        }
+    }
+}
+
+struct GutterButtonTooltip {
+    primary: GutterButtonIntent,
+    secondary: GutterButtonIntent,
+    focus_handle: FocusHandle,
+}
+
+impl GutterButtonTooltip {
+    fn active_intent(&self, modifiers: Modifiers) -> GutterButtonIntent {
+        if modifiers.secondary() {
+            self.secondary
+        } else {
+            self.primary
+        }
+    }
+
+    fn meta_text(&self, intent: GutterButtonIntent) -> String {
+        const RIGHT_CLICK_HINT: &str = "right-click for more options";
+
+        if self.primary == self.secondary {
+            return RIGHT_CLICK_HINT.to_string();
+        }
+        let modifier_as_text = gpui::Keystroke {
+            modifiers: Modifiers::secondary_key(),
+            ..Default::default()
+        };
+        let other = match intent {
+            GutterButtonIntent::SetBookmark => "breakpoint",
+            GutterButtonIntent::SetBreakpoint => "bookmark",
+        };
+        format!("{modifier_as_text}-click to add a {other}\n{RIGHT_CLICK_HINT}")
+    }
+}
+
+impl Render for GutterButtonTooltip {
+    fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        let intent = self.active_intent(window.modifiers());
+        let key_binding = KeyBinding::for_action_in(intent.action(), &self.focus_handle, cx);
+        let meta_text = self.meta_text(intent);
+
+        tooltip_container(cx, move |this, _| {
+            this.child(
+                h_flex()
+                    .justify_between()
+                    .child(intent.as_str())
+                    .child(key_binding),
+            )
+            .child(
+                Label::new(meta_text)
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            )
+        })
+    }
+}
+
 impl Editor {
     pub fn single_line(window: &mut Window, cx: &mut Context) -> Self {
         let buffer = cx.new(|cx| Buffer::local("", cx));
@@ -2190,7 +2282,6 @@ impl Editor {
             use_relative_line_numbers: None,
             disable_expand_excerpt_buttons: !full_mode,
             delegate_expand_excerpts: false,
-            delegate_stage_and_restore: false,
             delegate_open_excerpts: false,
             enable_lsp_data: full_mode,
             needs_initial_data_update: full_mode,
@@ -2295,7 +2386,6 @@ impl Editor {
             show_git_blame_inline_delay_task: None,
             git_blame_inline_enabled: full_mode
                 && ProjectSettings::get_global(cx).git.inline_blame.enabled,
-            render_diff_hunk_controls: Arc::new(render_diff_hunk_controls),
             buffer_serialization: is_minimap.not().then(|| {
                 BufferSerialization::new(
                     ProjectSettings::get_global(cx)
@@ -2305,6 +2395,7 @@ impl Editor {
             }),
             blame: None,
             blame_subscription: None,
+            pending_blame_hover_observation: None,
 
             bookmark_store,
             breakpoint_store,
@@ -2325,16 +2416,6 @@ impl Editor {
                         cx.observe_global_in::(window, Self::settings_changed),
                         cx.observe_global_in::(window, Self::theme_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);
-                                }
-                            });
-                        }),
                     ]
                 })
                 .unwrap_or_default(),
@@ -2364,8 +2445,7 @@ impl Editor {
             serialize_folds: Task::ready(()),
             text_style_refinement: None,
             load_diff_task: load_uncommitted_diff,
-            temporary_diff_override: false,
-            render_diff_hunks_as_unstaged: false,
+            diff_hunk_delegate: None,
             minimap: None,
             change_list: ChangeList::new(),
             mode,
@@ -3039,6 +3119,35 @@ impl Editor {
         self.cursor_offset_on_selection = set_cursor_offset_on_selection;
     }
 
+    /// Returns the anchor to use as the rename target for a selection.
+    ///
+    /// In selection-based modes, like vim's visual mode and helix, the rendered
+    /// block cursor sits one position to the left of the selection's head,
+    /// since the head is the exclusive end of a forward selection. Using the
+    /// head
+    /// directly would place the rename one character past the symbol, for
+    /// example, trailing whitespace, so for a non-empty forward selection we
+    /// shift one point left to land back on the symbol under the cursor.
+    fn rename_target_anchor(&self, selection: &Selection, cx: &mut App) -> Anchor {
+        let head = selection.head();
+
+        if self.cursor_offset_on_selection
+            && !selection.reversed
+            && selection.start != selection.end
+        {
+            let display_map = self.display_snapshot(cx);
+            let display_head = head.to_display_point(&display_map);
+
+            if display_head.column() > 0 {
+                return display_map.display_point_to_anchor(
+                    movement::left(&display_map, display_head),
+                    Bias::Left,
+                );
+            }
+        }
+        head
+    }
+
     pub fn set_current_line_highlight(
         &mut self,
         current_line_highlight: Option,
@@ -3961,8 +4070,8 @@ impl Editor {
             .size(ui::ButtonSize::None)
             .icon_color(Color::Info)
             .style(ButtonStyle::Transparent)
-            .on_click(cx.listener(move |editor, _, window, cx| {
-                editor.toggle_bookmark_at_row(row, window, cx);
+            .on_click(cx.listener(move |editor, _, _window, cx| {
+                editor.toggle_bookmark_at_row(row, cx);
             }))
             .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
                 editor.set_gutter_context_menu(row, None, event.position(), window, cx);
@@ -4253,10 +4362,10 @@ impl Editor {
                 .separator()
                 .entry(set_bookmark_msg, Some(ToggleBookmark.boxed_clone()), {
                     let weak_editor = weak_editor.clone();
-                    move |window, cx| {
+                    move |_window, cx| {
                         weak_editor
                             .update(cx, |this, cx| {
-                                this.toggle_bookmark_at_anchor(anchor, window, cx);
+                                this.toggle_bookmark_at_anchor(anchor, cx);
                             })
                             .log_err();
                     }
@@ -4371,61 +4480,20 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) -> IconButton {
-        #[derive(Clone, Copy)]
-        enum Intent {
-            SetBookmark,
-            SetBreakpoint,
-        }
-
-        impl Intent {
-            fn as_str(&self) -> &'static str {
-                match self {
-                    Intent::SetBookmark => "Set bookmark",
-                    Intent::SetBreakpoint => "Set breakpoint",
-                }
-            }
-
-            fn icon(&self) -> ui::IconName {
-                match self {
-                    Intent::SetBookmark => ui::IconName::Bookmark,
-                    Intent::SetBreakpoint => ui::IconName::DebugBreakpoint,
-                }
-            }
-
-            fn color(&self) -> Color {
-                match self {
-                    Intent::SetBookmark => Color::Info,
-                    Intent::SetBreakpoint => Color::Hint,
-                }
-            }
-
-            fn secondary_and_options(&self) -> String {
-                let alt_as_text = gpui::Keystroke {
-                    modifiers: Modifiers::secondary_key(),
-                    ..Default::default()
-                };
-                match self {
-                    Intent::SetBookmark => format!(
-                        "{alt_as_text}-click to add a breakpoint\nright-click for more options"
-                    ),
-                    Intent::SetBreakpoint => format!(
-                        "{alt_as_text}-click to add a bookmark\nright-click for more options"
-                    ),
-                }
-            }
-        }
-
         let gutter_settings = EditorSettings::get_global(cx).gutter;
         let show_bookmarks = self.show_bookmarks.unwrap_or(gutter_settings.bookmarks);
         let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints);
 
         let [primary, secondary] = match [show_breakpoints, show_bookmarks] {
-            [true, true] => [Intent::SetBreakpoint, Intent::SetBookmark],
-            [true, false] => [Intent::SetBreakpoint; 2],
-            [false, true] => [Intent::SetBookmark; 2],
+            [true, true] => [
+                GutterButtonIntent::SetBreakpoint,
+                GutterButtonIntent::SetBookmark,
+            ],
+            [true, false] => [GutterButtonIntent::SetBreakpoint; 2],
+            [false, true] => [GutterButtonIntent::SetBookmark; 2],
             [false, false] => {
                 log::error!("Trying to place gutter_hover without anything enabled!!");
-                [Intent::SetBookmark; 2]
+                [GutterButtonIntent::SetBookmark; 2]
             }
         };
 
@@ -4452,8 +4520,8 @@ impl Editor {
                     };
 
                     match intent {
-                        Intent::SetBookmark => editor.toggle_bookmark_at_row(row, window, cx),
-                        Intent::SetBreakpoint => editor.edit_breakpoint_at_anchor(
+                        GutterButtonIntent::SetBookmark => editor.toggle_bookmark_at_row(row, cx),
+                        GutterButtonIntent::SetBreakpoint => editor.edit_breakpoint_at_anchor(
                             position,
                             Breakpoint::new_standard(),
                             BreakpointEditAction::Toggle,
@@ -4467,13 +4535,12 @@ impl Editor {
             }))
             .when(!has_context_menu, |button| {
                 button.tooltip(move |_window, cx| {
-                    Tooltip::with_meta_in(
-                        intent.as_str(),
-                        Some(&ToggleBreakpoint),
-                        intent.secondary_and_options(),
-                        &focus_handle,
-                        cx,
-                    )
+                    cx.new(|_| GutterButtonTooltip {
+                        primary,
+                        secondary,
+                        focus_handle: focus_handle.clone(),
+                    })
+                    .into()
                 })
             })
     }
@@ -5485,24 +5552,36 @@ impl Editor {
                                 .documentation_comment()
                                 .map(|c| c.prefix.as_ref())
                                 .filter(|p| !p.is_empty());
-                            let all_prefixes = language_scope
+                            let comment_prefixes = language_scope
                                 .line_comment_prefixes()
                                 .iter()
                                 .map(|p| p.as_ref())
                                 .chain(block_prefix)
                                 .chain(doc_prefix)
-                                .chain(language_scope.unordered_list().iter().map(|p| p.as_ref()));
+                                .map(|prefix| (prefix, false));
+                            let all_prefixes = comment_prefixes.chain(
+                                language_scope
+                                    .unordered_list()
+                                    .iter()
+                                    .map(|prefix| (prefix.as_ref(), true)),
+                            );
 
                             let mut longest_prefix_len = None;
-                            for prefix in all_prefixes {
+                            for (prefix, is_unordered_list) in all_prefixes {
                                 let trimmed = prefix.trim_end();
-                                if line_text_after_indent.starts_with(trimmed) {
-                                    let candidate_len =
-                                        if line_text_after_indent.starts_with(prefix) {
-                                            prefix.len()
-                                        } else {
-                                            trimmed.len()
-                                        };
+                                let matches_full_prefix =
+                                    line_text_after_indent.starts_with(prefix);
+                                let nextline_is_bare_prefix = line_text_after_indent == trimmed;
+                                if matches_full_prefix
+                                    || (!is_unordered_list
+                                        && line_text_after_indent.starts_with(trimmed))
+                                    || nextline_is_bare_prefix
+                                {
+                                    let candidate_len = if matches_full_prefix {
+                                        prefix.len()
+                                    } else {
+                                        trimmed.len()
+                                    };
                                     if longest_prefix_len.map_or(true, |len| candidate_len > len) {
                                         longest_prefix_len = Some(candidate_len);
                                     }
@@ -7466,26 +7545,31 @@ impl Editor {
         });
     }
 
+    fn restore_selections(
+        &mut self,
+        selections: Option]>>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if let Some(selections) = selections.filter(|selections| !selections.is_empty()) {
+            self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                s.select_anchors(selections.to_vec());
+            });
+        }
+    }
+
     pub fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) {
         if self.read_only(cx) {
             return;
         }
 
         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(SelectionEffects::no_scroll(), 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"
-                );
+            let transaction = self.selection_history.transaction(transaction_id);
+            if transaction.is_none() {
+                log::error!("No selection history for undone transaction; selection unchanged");
             }
+            let selections = transaction.map(|transaction| transaction.undo.clone());
+            self.restore_selections(selections, window, cx);
             self.request_autoscroll(Autoscroll::fit(), cx);
             self.unmark_text(window, cx);
             self.refresh_edit_prediction(
@@ -7506,20 +7590,11 @@ impl Editor {
         }
 
         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(SelectionEffects::no_scroll(), 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"
-                );
-            }
+            let selections = self
+                .selection_history
+                .transaction(transaction_id)
+                .and_then(|transaction| transaction.redo.clone());
+            self.restore_selections(selections, window, cx);
             self.request_autoscroll(Autoscroll::fit(), cx);
             self.unmark_text(window, cx);
             self.refresh_edit_prediction(
@@ -7632,7 +7707,10 @@ impl Editor {
         }
         let provider = self.semantics_provider.clone()?;
         let selection = self.selections.newest_anchor().clone();
-        let (cursor_buffer, cursor_buffer_position) = self
+        let cursor = self.rename_target_anchor(&selection, cx);
+        let (cursor_buffer, cursor_buffer_position) =
+            self.buffer.read(cx).text_anchor_for_position(cursor, cx)?;
+        let (head_buffer, head_buffer_position) = self
             .buffer
             .read(cx)
             .text_anchor_for_position(selection.head(), cx)?;
@@ -7640,12 +7718,13 @@ impl Editor {
             .buffer
             .read(cx)
             .text_anchor_for_position(selection.tail(), cx)?;
-        if tail_buffer != cursor_buffer {
+        if tail_buffer != cursor_buffer || head_buffer != cursor_buffer {
             return None;
         }
 
         let snapshot = cursor_buffer.read(cx).snapshot();
         let cursor_buffer_offset = cursor_buffer_position.to_offset(&snapshot);
+        let head_buffer_offset = head_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);
         drop(snapshot);
@@ -7658,12 +7737,14 @@ impl Editor {
                     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 =
+                    let head_offset_in_rename_range =
+                        head_buffer_offset.saturating_sub(rename_buffer_range.start);
+                    let tail_offset_in_rename_range =
                         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 cursor_offset = cursor.to_offset(&buffer);
                     let rename_start =
                         cursor_offset.saturating_sub_usize(cursor_offset_in_rename_range);
                     let rename_end = rename_start + rename_buffer_range.len();
@@ -7699,24 +7780,23 @@ impl Editor {
                                 cx,
                             )
                         });
-                        let cursor_offset_in_rename_range =
-                            MultiBufferOffset(cursor_offset_in_rename_range);
-                        let cursor_offset_in_rename_range_end =
-                            MultiBufferOffset(cursor_offset_in_rename_range_end);
-                        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
-                            }
-                        };
+                        let head_offset_in_rename_range =
+                            MultiBufferOffset(head_offset_in_rename_range);
+                        let tail_offset_in_rename_range =
+                            MultiBufferOffset(tail_offset_in_rename_range);
+                        let rename_selection_range =
+                            match head_offset_in_rename_range.cmp(&tail_offset_in_rename_range) {
+                                Ordering::Equal => {
+                                    editor.select_all(&SelectAll, window, cx);
+                                    return editor;
+                                }
+                                Ordering::Less => {
+                                    head_offset_in_rename_range..tail_offset_in_rename_range
+                                }
+                                Ordering::Greater => {
+                                    tail_offset_in_rename_range..head_offset_in_rename_range
+                                }
+                            };
                         if rename_selection_range.end.0 > old_name.len() {
                             editor.select_all(&SelectAll, window, cx);
                         } else {
@@ -8033,7 +8113,7 @@ impl Editor {
                 // 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())
+                    .map(|t| t.undo.clone())
             })
             .unwrap_or_else(|| self.selections.disjoint_anchors_arc());
 
@@ -8259,10 +8339,8 @@ impl Editor {
             .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_arc());
+            if let Some(transaction) = self.selection_history.transaction_mut(transaction_id) {
+                transaction.redo = Some(self.selections.disjoint_anchors_arc());
             } else {
                 log::error!("unexpectedly ended a transaction that wasn't started by this editor");
             }
@@ -8277,7 +8355,7 @@ impl Editor {
     pub fn modify_transaction_selection_history(
         &mut self,
         transaction_id: TransactionId,
-        modify: impl FnOnce(&mut (Arc<[Selection]>, Option]>>)),
+        modify: impl FnOnce(&mut TransactionSelections),
     ) -> bool {
         self.selection_history
             .transaction_mut(transaction_id)
@@ -8584,19 +8662,30 @@ impl Editor {
         cx: &mut Context,
     ) {
         let selection = self.selections.newest::(&self.display_snapshot(cx));
+        let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx);
+
+        if let Some(file_location) = maybe!({
+            let (buffer, range) = multi_buffer_snapshot
+                .range_to_buffer_range(selection.range())
+                .or_else(|| {
+                    // A selection that spans multiple buffers has no single location,
+                    // so fall back to the buffer the latest cursor is in.
+                    let (buffer, point) =
+                        multi_buffer_snapshot.point_to_buffer_point(selection.head())?;
+                    Some((buffer, point..point))
+                })?;
 
-        let start_line = selection.start.row + 1;
-        let end_line = selection.end.row + 1;
+            let start_line = range.start.row + 1;
+            let end_line = range.end.row + 1;
 
-        let end_line = if selection.end.column == 0 && end_line > start_line {
-            end_line - 1
-        } else {
-            end_line
-        };
+            let end_line = if range.end.column == 0 && end_line > start_line {
+                end_line - 1
+            } else {
+                end_line
+            };
 
-        if let Some(file_location) = self.active_buffer(cx).and_then(|buffer| {
             let project = self.project()?.read(cx);
-            let file = buffer.read(cx).file()?;
+            let file = buffer.file()?;
             let path = file.path().display(project.path_style(cx));
 
             let location = if start_line == end_line {
@@ -9493,6 +9582,9 @@ impl Editor {
                 ranges,
                 path_key,
             } => {
+                if let Some(hovered_link_state) = self.hovered_link_state.as_mut() {
+                    hovered_link_state.symbol_range = None;
+                }
                 self.refresh_document_highlights(cx);
                 let buffer_id = buffer.read(cx).remote_id();
                 if self.buffer.read(cx).diff_for(buffer_id).is_none()
@@ -9612,6 +9704,13 @@ impl Editor {
         let theme_settings = theme_settings::ThemeSettings::get_global(cx);
         let theme = cx.theme();
         let accent_colors = theme.accents().clone();
+        let editor_background = theme.colors().editor_background;
+        let auto_accent_colors =
+            AccentColors(crate::bracket_colorization::bracket_colorization_accents(
+                &accent_colors.0,
+                theme.appearance,
+                editor_background,
+            ));
 
         let accent_overrides = theme_settings
             .theme_overrides
@@ -9631,7 +9730,7 @@ impl Editor {
             .collect();
 
         Some(AccentData {
-            colors: accent_colors,
+            colors: auto_accent_colors,
             overrides: accent_overrides,
         })
     }
@@ -9674,7 +9773,13 @@ impl Editor {
         }
         self.refresh_runnables(None, window, cx);
         self.update_edit_prediction_settings(cx);
-        self.refresh_edit_prediction(true, false, EditPredictionRequestTrigger::Other, window, cx);
+        self.refresh_edit_prediction(
+            true,
+            false,
+            EditPredictionRequestTrigger::SettingsChanged,
+            window,
+            cx,
+        );
         self.refresh_inline_values(cx);
 
         let old_cursor_shape = self.cursor_shape;
@@ -10828,7 +10933,7 @@ impl Editor {
                         if multibuffer.is_singleton() {
                             multibuffer.title(cx).to_string()
                         } else {
-                            "untitled".to_string()
+                            MultiBuffer::DEFAULT_TITLE.to_string()
                         }
                     })
             });
@@ -11509,15 +11614,12 @@ impl EditorSnapshot {
                         let renderer = cx.global::().0.clone();
                         const MAX_RELATIVE_TIMESTAMP: &str = "2 years, 11 months 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;
+                            + MAX_RELATIVE_TIMESTAMP.len();
 
                         ch_advance * max_char_count
+                            + renderer.blame_entry_non_text_width(window, cx)
                     });
 
             let is_singleton = self.buffer_snapshot().is_singleton();
@@ -11607,30 +11709,50 @@ impl EditorSnapshot {
         current_selection_head: DisplayRow,
         count_wrapped_lines: bool,
     ) -> HashMap {
-        let initial_offset =
-            self.relative_line_delta(current_selection_head, rows.start, count_wrapped_lines);
-
-        self.row_infos(rows.start)
+        let mut row_infos = self
+            .row_infos(rows.start)
             .take(rows.len())
             .enumerate()
-            .map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info))
+            .map(|(index, row_info)| (DisplayRow(rows.start.0 + index as u32), row_info))
             .filter(|(_row, row_info)| {
                 row_info.buffer_row.is_some()
                     || (count_wrapped_lines && row_info.wrapped_buffer_row.is_some())
             })
-            .enumerate()
-            .filter_map(|(i, (row, row_info))| {
+            .peekable();
+
+        // We find the first row that actually passes the filter and calculate its
+        // delta independently. This ensures accuracy when scrolling, as the first
+        // visible row in `rows` might be a wrap part that is filtered out, which
+        // would otherwise offset the counter for subsequent lines if we used
+        // `rows.start` as the base for enumeration.
+        let Some((first_row, _)) = row_infos.peek() else {
+            return HashMap::default();
+        };
+
+        let mut current_delta =
+            self.relative_line_delta(current_selection_head, *first_row, count_wrapped_lines);
+
+        row_infos
+            .filter_map(|(row, row_info)| {
+                let is_deleted = row_info
+                    .diff_status
+                    .is_some_and(|status| status.is_deleted());
+
+                if !self.number_deleted_lines && is_deleted {
+                    // Even if we don't number this line, it still counts as a unit
+                    // of distance for the relative numbers of lines below it.
+                    current_delta += 1;
+                    return None;
+                }
+
                 // We want to ensure here that the current line has absolute
                 // numbering, even if we are in a soft-wrapped line. With the
                 // exception that if we are in a deleted line, we should number this
                 // relative with 0, as otherwise it would have no line number at all
-                let relative_line_number = (initial_offset + i as i64).unsigned_abs() as u32;
+                let relative_line_number = current_delta.unsigned_abs() as u32;
+                current_delta += 1;
 
-                (relative_line_number != 0
-                    || row_info
-                        .diff_status
-                        .is_some_and(|status| status.is_deleted()))
-                .then_some((row, relative_line_number))
+                (relative_line_number != 0 || is_deleted).then_some((row, relative_line_number))
             })
             .collect()
     }
@@ -11695,17 +11817,10 @@ pub enum EditorEvent {
         lines: u32,
         direction: ExpandExcerptDirection,
     },
-    StageOrUnstageRequested {
-        stage: bool,
-        hunks: Vec,
-    },
     OpenExcerptsRequested {
         selections_by_buffer: HashMap>, Option)>,
         split: bool,
     },
-    RestoreRequested {
-        hunks: Vec,
-    },
     /// Emitted when an underlying buffer changes, including edits made through another editor.
     BufferEdited,
     /// Emitted when this editor creates, undoes, or redoes an edit transaction.
diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs
index c10f954a7617f9..8d95eb08b60954 100644
--- a/crates/editor/src/editor_settings.rs
+++ b/crates/editor/src/editor_settings.rs
@@ -7,8 +7,8 @@ pub use settings::{
     CodeLens, CompletionDetailAlignment, CompletionMenuItemKind, CurrentLineHighlight, DelayMs,
     DiffViewStyle, DisplayIn, DocumentColorsRenderMode, DoubleClickInMultibuffer,
     GoToDefinitionFallback, GoToDefinitionScrollStrategy, MinimapThumb, MinimapThumbBorder,
-    MultiCursorModifier, ScrollBeyondLastLine, ScrollbarDiagnostics, SeedQuerySetting, ShowMinimap,
-    SnippetSortOrder,
+    MultiCursorModifier, OpenResultsIn, ScrollBeyondLastLine, ScrollbarDiagnostics,
+    SeedQuerySetting, ShowMinimap, SnippetSortOrder,
 };
 use settings::{RegisterSetting, RelativeLineNumbers, Settings};
 use ui::scrollbars::ShowScrollbar;
@@ -54,6 +54,7 @@ pub struct EditorSettings {
     pub show_signature_help_after_edits: bool,
     pub go_to_definition_fallback: GoToDefinitionFallback,
     pub go_to_definition_scroll_strategy: GoToDefinitionScrollStrategy,
+    pub lsp_results_location: OpenResultsIn,
     pub jupyter: Jupyter,
     pub snippet_sort_order: SnippetSortOrder,
     pub diagnostics_max_severity: Option,
@@ -295,6 +296,7 @@ impl Settings for EditorSettings {
             show_signature_help_after_edits: editor.show_signature_help_after_edits.unwrap(),
             go_to_definition_fallback: editor.go_to_definition_fallback.unwrap(),
             go_to_definition_scroll_strategy: editor.go_to_definition_scroll_strategy.unwrap(),
+            lsp_results_location: editor.lsp_results_location.unwrap(),
             jupyter: Jupyter {
                 enabled: editor.jupyter.unwrap().enabled.unwrap(),
             },
diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs
index 6b43dff2cc9af1..a44b8000ef3d88 100644
--- a/crates/editor/src/editor_tests.rs
+++ b/crates/editor/src/editor_tests.rs
@@ -27,9 +27,10 @@ use language::{
     Capability::ReadWrite,
     ContextLocation, ContextProvider, DiagnosticSourceKind, FakeLspAdapter, IndentGuideSettings,
     LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries,
-    LanguageToolchainStore, Override, Point,
+    LanguageToolchainStore, Override, PLAIN_TEXT, Point,
     language_settings::{
-        CompletionSettingsContent, FormatterList, LanguageSettingsContent, LspInsertMode,
+        CompletionSettingsContent, FormatOnSave, FormatterList, LanguageSettingsContent,
+        LspInsertMode,
     },
     tree_sitter_python,
 };
@@ -54,12 +55,12 @@ use settings::{
     InlayHintSettingsContent, ProjectSettingsContent, ScrollBeyondLastLine, SearchSettingsContent,
     SettingsContent, SettingsStore,
 };
-use std::{borrow::Cow, sync::Arc};
-use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
 use std::{
-    iter,
-    sync::atomic::{self, AtomicUsize},
+    borrow::Cow,
+    sync::{Arc, atomic},
 };
+use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
+use std::{iter, sync::atomic::AtomicUsize};
 use task::TaskVariables;
 use test::build_editor_with_project;
 use unindent::Unindent;
@@ -323,6 +324,39 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_undo_redo_with_empty_history_selections_does_not_panic(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let now = Instant::now();
+    let buffer = cx.new(|cx| language::Buffer::local("123456", cx));
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let editor = cx.add_window(|window, cx| build_editor(buffer, window, cx));
+
+    _ = editor.update(cx, |editor, window, cx| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)])
+        });
+        editor.start_transaction_at(now, window, cx);
+        editor.insert("a", window, cx);
+        let transaction_id = editor
+            .end_transaction_at(now, cx)
+            .expect("transaction should be created");
+        assert_eq!(editor.text(cx), "a123456");
+
+        editor.modify_transaction_selection_history(transaction_id, |selections| {
+            selections.undo = Vec::new().into();
+            selections.redo = Some(Vec::new().into());
+        });
+
+        editor.undo(&Undo, window, cx);
+        assert_eq!(editor.text(cx), "123456");
+
+        editor.redo(&Redo, window, cx);
+        assert_eq!(editor.text(cx), "a123456");
+    });
+}
+
 #[gpui::test]
 fn test_accessibility_keyboard_word_completion(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -822,6 +856,107 @@ fn test_extending_selection(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_extend_selection_after_collapsed_word_selection(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let editor = cx.add_window(|window, cx| {
+        let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx);
+        build_editor(buffer, window, cx)
+    });
+
+    _ = editor.update(cx, |editor, window, cx| {
+        // Double-click "bbb" to select it word-wise.
+        editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 2, window, cx);
+        editor.end_selection(window, cx);
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)]
+        );
+
+        // Move the cursor with the keyboard, collapsing the selection.
+        editor.move_right(&MoveRight, window, cx);
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7)]
+        );
+
+        // Shift+click further along the line should extend from the cursor,
+        // not from the stale double-clicked word.
+        editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx);
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 10)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_extend_selection_after_collapsed_word_selection_moved_via_offsets(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let editor = cx.add_window(|window, cx| {
+        let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx);
+        build_editor(buffer, window, cx)
+    });
+
+    _ = editor.update(cx, |editor, window, cx| {
+        // Double-click "bbb" to select it word-wise.
+        editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 2, window, cx);
+        editor.end_selection(window, cx);
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)]
+        );
+
+        // Collapse the selection through `move_offsets_with`, the code path
+        // used by non-keyboard-movement callers like
+        // `move_to_enclosing_bracket` and `select_delimiters_impl`.
+        editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+            s.move_offsets_with(&mut |_, selection| {
+                selection.collapse_to(MultiBufferOffset(7), SelectionGoal::None);
+            });
+        });
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7)]
+        );
+
+        // Shift+click further along the line should extend from the cursor,
+        // not from the stale double-clicked word.
+        editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx);
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 10)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_extend_selection_from_empty_line_selection(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let editor = cx.add_window(|window, cx| {
+        let buffer = MultiBuffer::build_simple("aaa\nbbb\n", cx);
+        build_editor(buffer, window, cx)
+    });
+
+    _ = editor.update(cx, |editor, window, cx| {
+        editor.begin_selection(DisplayPoint::new(DisplayRow(2), 0), false, 3, window, cx);
+        editor.end_selection(window, cx);
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0)]
+        );
+
+        editor.extend_selection(DisplayPoint::new(DisplayRow(0), 1), 1, window, cx);
+        assert_eq!(
+            display_ranges(editor, cx),
+            [DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(0), 0)]
+        );
+    });
+}
+
 #[gpui::test]
 fn test_clone(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2451,6 +2586,45 @@ fn test_beginning_of_line_single_line_editor(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_only_focused_editor_blinks_across_window_activation(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let window = cx.add_window(|window, cx| Editor::single_line(window, cx));
+    let unfocused_editor = window
+        .update(cx, |focused_editor, window, cx| {
+            window.focus(&focused_editor.focus_handle(cx), cx);
+            let unfocused_editor = cx.new(|cx| Editor::single_line(window, cx));
+            window.activate_window();
+            unfocused_editor
+        })
+        .unwrap();
+    let focused_editor = window.root(cx).unwrap();
+    let cx = &mut VisualTestContext::from_window(*window, cx);
+    cx.run_until_parked();
+
+    cx.update(|window, cx| {
+        assert!(window.is_window_active());
+        assert!(focused_editor.read(cx).blink_manager.read(cx).enabled());
+        assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled());
+    });
+
+    cx.deactivate_window();
+    cx.update(|window, cx| {
+        assert!(!window.is_window_active());
+        assert!(!focused_editor.read(cx).blink_manager.read(cx).enabled());
+        assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled());
+    });
+
+    cx.update(|window, _| window.activate_window());
+    cx.run_until_parked();
+    cx.update(|window, cx| {
+        assert!(window.is_window_active());
+        assert!(focused_editor.read(cx).blink_manager.read(cx).enabled());
+        assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled());
+    });
+}
+
 #[gpui::test]
 fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2864,6 +3038,237 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext)
     cx.assert_editor_state(&"ˇone\ntwo\n \nthree\nfour\nfive\n\nsix");
 }
 
+#[gpui::test]
+async fn test_move_to_next_and_previous_comment_paragraph(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                line_comments: vec!["// ".into()],
+                ..LanguageConfig::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
+        .unwrap(),
+    );
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    // A blank comment line (`//`) splits one comment block into two paragraphs;
+    // a code line splits blocks; and a trailing comment preceded by code
+    // (`let x = 1; // ...`) is not a comment line at all.
+    cx.set_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    cx.run_until_parked();
+
+    let next = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_next_comment_paragraph(&MoveToNextCommentParagraph, window, cx)
+        });
+    };
+    let prev = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx)
+        });
+    };
+
+    // Forward: skip the second line of the first paragraph, the blank comment
+    // line, the code line, and the trailing comment line.
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        ˇ// second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        ˇ// third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        ˇ// fourth paragraph
+    "});
+    // No paragraph after the last one: the caret stays put.
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        ˇ// fourth paragraph
+    "});
+
+    // Backward is the mirror image.
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        ˇ// third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        ˇ// second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    // No paragraph before the first one: the caret stays put.
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+}
+
+#[gpui::test]
+async fn test_move_to_previous_comment_paragraph_skips_current_paragraph(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                line_comments: vec!["// ".into()],
+                ..LanguageConfig::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
+        .unwrap(),
+    );
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    let prev = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx)
+        });
+    };
+
+    // Caret in the middle of the last line of the second paragraph: moving to
+    // the previous paragraph must skip the entire current paragraph and land on
+    // the first paragraph's start, not on the current paragraph's own start.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta ˇtwo
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+
+    // Same when the caret is part-way through the *first* line of the second
+    // paragraph.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha two
+        // alpha three
+
+        // beˇta one
+        // beta two
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+
+    // Inside the first paragraph there is no previous paragraph, so the caret
+    // stays put rather than jumping to the current paragraph's own start.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha ˇtwo
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // alpha one
+        // alpha ˇtwo
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+}
+
 #[gpui::test]
 async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2925,6 +3330,90 @@ async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_scroll_line_up_down_cursor_margin(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+    let line_height = cx.update_editor(|editor, window, cx| {
+        editor.set_vertical_scroll_margin(2, cx);
+        editor
+            .style(cx)
+            .text
+            .line_height_in_pixels(window.rem_size())
+    });
+    let window = cx.window;
+    // 5 visible lines with margin=2: valid cursor rows are [top+2 .. top+2] (only the middle row)
+    cx.simulate_window_resize(window, size(px(1000.), 5. * line_height));
+
+    // Cursor at row 0 — autoscroll leaves viewport at y=0.
+    cx.set_state(indoc! {"
+        ˇone
+        two
+        three
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ten
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+
+        // Scroll down 1: top=1, visible rows 1-5, margin=2 → min_row=3, max_row=3.
+        // Cursor at row 0 < min_row=3 → clamped to row 3.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 1.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            3,
+            "cursor clamped to min_row after scrolling down"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Scroll up 1: top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2.
+        // Cursor at row 3 > max_row=2 → clamped to row 2.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            2,
+            "cursor clamped to max_row after scrolling up"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Half page down (2 lines): top=2, visible rows 2-6, margin=2 → min_row=4, max_row=4.
+        // Cursor at row 2 < min_row=4 → clamped to row 4.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 2.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            4,
+            "cursor clamped to min_row after scrolling half page down"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Half page up (2 lines): top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2.
+        // Cursor at row 4 > max_row=2 → clamped to row 2.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            2,
+            "cursor clamped to max_row after scrolling half page up"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_autoscroll(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -6192,6 +6681,24 @@ async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) {
             + fooˇ bar
         "});
 
+        cx.set_state(indoc! {"
+            fooˇ
+            *bar*
+        "});
+        cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
+        cx.assert_editor_state(indoc! {"
+            fooˇ *bar*
+        "});
+
+        cx.set_state(indoc! {"
+            * ˇfoo
+            *
+        "});
+        cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
+        cx.assert_editor_state(indoc! {"
+            * fooˇ
+        "});
+
         // No-whitespace join also strips the list marker.
         cx.set_state(indoc! {"
             - ˇfoo
@@ -8992,6 +9499,110 @@ async fn test_cut_line_ends(cx: &mut TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_kill_ring_cut_accumulates_multi_line_kills(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇone\ntwo");
+
+    cx.update_editor(|e, window, cx| {
+        e.kill_ring_cut(&KillRingCut, window, cx);
+        e.kill_ring_cut(&KillRingCut, window, cx);
+    });
+
+    cx.update_editor(|e, window, cx| e.kill_ring_yank(&KillRingYank, window, cx));
+    cx.assert_editor_state("one\nˇtwo");
+}
+
+#[gpui::test]
+async fn test_kill_ring_cut_matches_emacs_kill_line_sequence_at_end_of_buffer(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇa\nb\nc");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "\nc");
+    });
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "a\nb\nc");
+    });
+}
+
+#[gpui::test]
+async fn test_kill_ring_cut_accumulates_final_line_without_trailing_newline(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇa\nb\nc");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "");
+    });
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "a\nb\nc");
+    });
+}
+
+#[gpui::test]
+async fn test_kill_ring_yank_breaks_kill_ring_cut_accumulation(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇa\nb\nc");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.update_editor(|editor, window, cx| editor.move_to_beginning(&MoveToBeginning, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "a\nb\nc");
+    });
+}
+
+#[gpui::test]
+async fn test_kill_ring_yank_pastes_accumulated_kill_at_each_cursor(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇone\ntwo");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+
+    cx.set_state("aˇ bˇ");
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.assert_editor_state("aone\nˇ bone\nˇ");
+}
+
 #[gpui::test]
 async fn test_clipboard(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -9400,6 +10011,260 @@ async fn test_clipboard_line_numbers_from_multibuffer(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_copy_file_location_from_multibuffer(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "file.txt": "first line\nsecond line\nthird line\nfourth line\nfifth line\n",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/root/file.txt"), cx)
+        })
+        .await
+        .unwrap();
+
+    let multibuffer = cx.new(|cx| {
+        let mut multibuffer = MultiBuffer::new(ReadWrite);
+        multibuffer.set_excerpts_for_path(
+            PathKey::sorted(0),
+            buffer.clone(),
+            [Point::new(2, 0)..Point::new(5, 0)],
+            0,
+            cx,
+        );
+        multibuffer
+    });
+
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), multibuffer, window, cx)
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file.txt:3".to_string()),
+        "cursor on the first excerpt row should report its line in the original file"
+    );
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(1, 0)..Point::new(2, 0)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file.txt:4".to_string()),
+        "a selection ending at the start of a row should not include that row"
+    );
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(0, 0)..Point::new(2, 3)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file.txt:3-5".to_string()),
+        "a multi-row selection should report the original file's line range"
+    );
+}
+
+#[gpui::test]
+async fn test_copy_file_location_across_buffers(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "one.txt": "one\ntwo\nthree\n",
+            "two.txt": "four\nfive\nsix\n",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+
+    let buffer_1 = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/root/one.txt"), cx)
+        })
+        .await
+        .unwrap();
+    let buffer_2 = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/root/two.txt"), cx)
+        })
+        .await
+        .unwrap();
+
+    let multibuffer = cx.new(|cx| {
+        let mut multibuffer = MultiBuffer::new(ReadWrite);
+        multibuffer.set_excerpts_for_path(
+            PathKey::sorted(0),
+            buffer_1,
+            [Point::new(1, 0)..Point::new(3, 0)],
+            0,
+            cx,
+        );
+        multibuffer.set_excerpts_for_path(
+            PathKey::sorted(1),
+            buffer_2,
+            [Point::new(1, 0)..Point::new(3, 0)],
+            0,
+            cx,
+        );
+        multibuffer
+    });
+
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), multibuffer, window, cx)
+    });
+
+    // The multi-buffer reads "two\nthree\n\nfive\nsix\n", so row 3 is the first
+    // row of the second buffer's excerpt. Select from the first excerpt into it.
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(0, 0)..Point::new(3, 2)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("two.txt:2".to_string()),
+        "a selection spanning buffers should report the location of the cursor"
+    );
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(3, 2)..Point::new(0, 0)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("one.txt:2".to_string()),
+        "a reversed selection spanning buffers should report the cursor's location in the first buffer"
+    );
+}
+
+#[gpui::test]
+async fn test_copy_file_location_with_deleted_hunk(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇaaa\nbbb\nccc");
+    cx.set_head_text("aaa\nXXX\nbbb\nccc");
+    cx.run_until_parked();
+    cx.update_editor(|editor, window, cx| {
+        editor.expand_all_diff_hunks(&Default::default(), window, cx);
+    });
+    cx.run_until_parked();
+
+    // Multi-buffer rows: 0 "aaa", 1 "XXX" (deleted hunk), 2 "bbb", 3 "ccc"
+    cx.update_editor(|editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(0, 0)..Point::new(2, 2)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file:2".to_string()),
+        "a selection crossing a deleted hunk should report the location of the cursor"
+    );
+
+    cx.update_editor(|editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file:1".to_string())
+    );
+
+    cx.update_editor(|editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(1, 1)..Point::new(1, 1)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file:1".to_string()),
+        "a cursor inside a deleted hunk has no file location, so the clipboard is unchanged"
+    );
+}
+
+#[gpui::test]
+async fn test_copy_file_location_in_singleton_buffer(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "file.txt": "first line\nsecond line\nthird line\n",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/root/file.txt"), cx)
+        })
+        .await
+        .unwrap();
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), multibuffer, window, cx)
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file.txt:2".to_string())
+    );
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
+            selections.select_ranges([Point::new(0, 0)..Point::new(2, 4)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+    assert_eq!(
+        cx.read_from_clipboard().and_then(|item| item.text()),
+        Some("file.txt:1-3".to_string())
+    );
+}
+
 #[gpui::test]
 async fn test_paste_multiline(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -9524,6 +10389,9 @@ async fn test_paste_multiline(cx: &mut TestAppContext) {
     // Paste it on a line with a lower indent level
     cx.update_editor(|e, window, cx| e.move_to_end(&Default::default(), window, cx));
     cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx));
+    // Block auto-indent is applied asynchronously when it exceeds its
+    // synchronous time budget, so wait for it before asserting.
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         const a: B = (
             c(),
@@ -10852,7 +11720,12 @@ async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext)
 
     let provider = cx.new(|_| FakeEditPredictionDelegate::default());
     cx.update_editor(|editor, window, cx| {
-        editor.set_edit_prediction_provider(Some(provider.clone()), window, cx);
+        editor.set_edit_prediction_provider(
+            Some(provider.clone()),
+            EditPredictionRequestTrigger::EditorCreated,
+            window,
+            cx,
+        );
     });
 
     cx.set_state(indoc! {"
@@ -11939,9 +12812,9 @@ async fn test_fold_function_bodies(cx: &mut TestAppContext) {
             fn b() {
                 c();
             }
-      -
-      -     // this is another uncommitted comment
 
+      -     // this is another uncommitted comment
+      -
             fn d() {
                 // e
                 // f
@@ -12502,6 +13375,29 @@ async fn test_autoindent_selections(cx: &mut TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_autoclose_nested_brackets_in_plain_text(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let language = PLAIN_TEXT.clone();
+    cx.language_registry().add(language.clone());
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language(Some(language), cx);
+    });
+
+    cx.set_state("ˇ");
+
+    // Typing an opening bracket right before an existing closing bracket should
+    // still autoclose, not skip because the following char is `)`.
+    cx.update_editor(|editor, window, cx| {
+        editor.handle_input("(", window, cx);
+        editor.handle_input("[", window, cx);
+        editor.handle_input("{", window, cx);
+    });
+    cx.assert_editor_state("([{ˇ}])");
+}
+
 #[gpui::test]
 async fn test_autoclose_and_auto_surround_pairs(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -14950,6 +15846,89 @@ async fn setup_range_format_test(
     .await
 }
 
+/// Like `setup_range_format_test`, but backs the buffer with a FakeFs git
+/// repository so that `GitStore::get_unstaged_diff` returns a real diff.
+/// `head_content` sets the HEAD base, `index_content` sets the staged base.
+/// The buffer starts empty; the caller must `editor.set_text(...)` to set the
+/// working-tree content (the diff recomputes from buffer changes).
+async fn setup_range_format_test_with_git<'a>(
+    cx: &'a mut TestAppContext,
+    head_content: &str,
+    index_content: &str,
+) -> (
+    Entity,
+    Entity,
+    &'a mut gpui::VisualTestContext,
+    lsp::FakeLanguageServer,
+) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", index_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    // Open the unstaged diff so GitStore tracks this buffer. Without this,
+    // `get_unstaged_diff` returns None and compute_format_target cannot
+    // produce range-based FormatTarget.
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    (project, editor, cx, fake_server)
+}
+
 fn refresh_editor_actions(cx: &mut VisualTestContext) {
     cx.executor().run_until_parked();
     cx.update(|window, cx| {
@@ -15162,6 +16141,940 @@ async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) {
     assert!(!cx.read(|cx| editor.is_dirty(cx)));
 }
 
+#[gpui::test]
+async fn test_modifications_format_on_save(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    fake_server
+        .set_request_handler::(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+            );
+            Ok(Some(vec![lsp::TextEdit::new(
+                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
+                ", ".to_string(),
+            )]))
+        })
+        .next()
+        .await;
+    save.await;
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one, two\nthree\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+}
+
+#[gpui::test]
+async fn test_modifications_format_skips_without_git_diff(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    assert!(
+        cx.read(|cx| editor.is_dirty(cx)),
+        "editor should be dirty before save"
+    );
+
+    let _no_range_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when no git diff is available");
+        })
+        .next();
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called for Modifications without a git diff");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(
+        !cx.read(|cx| editor.is_dirty(cx)),
+        "the buffer should be saved despite the skipped formatting"
+    );
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one\ntwo\nthree\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_lsp_no_range_support(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    let head_content = "one\ntwo\nthree\n";
+    fs.set_head_and_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, 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();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\nTWO\nthree\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when range formatting is unsupported");
+        })
+        .next();
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called for Modifications when range formatting is unsupported");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one\nTWO\nthree\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_lsp_returns_empty_edits(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("aaa\nbbb\nccc\nddd\neee\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    fake_server
+        .set_request_handler::(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+            );
+            Ok(Some(Vec::new()))
+        })
+        .next()
+        .await;
+    save.await;
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "aaa\nbbb\nccc\nddd\neee\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_multiple_hunks(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\nline5\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\nline5\n", window, cx);
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            let count = request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move {
+                assert_eq!(
+                    params.text_document.uri,
+                    lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+                );
+                match count {
+                    0 => Ok(Some(vec![lsp::TextEdit::new(
+                        lsp::Range::new(lsp::Position::new(1, 5), lsp::Position::new(1, 5)),
+                        "!".to_string(),
+                    )])),
+                    1 => Ok(Some(vec![lsp::TextEdit::new(
+                        lsp::Range::new(lsp::Position::new(4, 5), lsp::Position::new(4, 5)),
+                        "!".to_string(),
+                    )])),
+                    _ => panic!("unexpected third range formatting request"),
+                }
+            }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2);
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "line0\nLINE1!\nline2\nline3\nLINE4!\nline5\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+}
+
+#[gpui::test]
+async fn test_modifications_format_excludes_staged_changes(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let staged_content = "line0\nLINE1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            assert_eq!(
+                params.range.start.line, 4,
+                "only the unstaged hunk (LINE4) should be formatted"
+            );
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        1,
+        "staged hunk (LINE1) must not be formatted, only the unstaged hunk (LINE4)"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_range_excludes_staged_hunk(cx: &mut TestAppContext) {
+    let head_content = "a\nb\nc\n";
+    let staged_content = "A\nb\nc\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("A\nB\nc\n", window, cx)
+    });
+    cx.run_until_parked();
+
+    let captured_start_line = Arc::new(AtomicUsize::new(usize::MAX));
+    let captured_start_line_clone = captured_start_line.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            captured_start_line_clone
+                .store(params.range.start.line as usize, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    save.await;
+
+    let start_line = captured_start_line.load(atomic::Ordering::SeqCst);
+    assert_ne!(
+        start_line,
+        usize::MAX,
+        "expected at least one range formatting request"
+    );
+    assert_eq!(
+        start_line, 1,
+        "format range must exclude the staged row and start at the unstaged row"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_pure_unstaged_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "unstaged hunks (LINE1, LINE4) must be formatted via the git diff path"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_no_unstaged_changes_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let staged_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(staged_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when all changes are staged");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn test_modifications_format_no_changes_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(head_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when buffer matches HEAD");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn test_modifications_format_crlf_line_endings(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "line0\r\nline1\r\nline2\r\nline3\r\nline4\r\n",
+        }),
+    )
+    .await;
+
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer.line_ending(),
+            language::LineEnding::Windows,
+            "buffer should detect CRLF line endings from the working tree file"
+        );
+    });
+
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\n", window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "CRLF line endings must not cause spurious diff hunks"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_merge_boundary_one_row_gap(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nLINE3\nline4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "hunks separated by exactly one unchanged row must not merge"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_empty_diff_skips_formatting(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(head_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when diff is empty");
+        })
+        .next();
+    let _no_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called when a diff is available but empty");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(
+        !cx.read(|cx| editor.is_dirty(cx)),
+        "the buffer should be saved despite the skipped formatting"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_no_git_falls_back_to_full_format(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_capabilities(
+        cx,
+        lsp::ServerCapabilities {
+            document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+            document_formatting_provider: Some(lsp::OneOf::Left(true)),
+            ..lsp::ServerCapabilities::default()
+        },
+    )
+    .await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when no git diff is available");
+        })
+        .next();
+
+    let formatting_called = Arc::new(AtomicBool::new(false));
+    let formatting_called_clone = formatting_called.clone();
+    let mut formatting_rx =
+        fake_server.set_request_handler::(move |_, _| {
+            formatting_called_clone.store(true, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    formatting_rx.next().await;
+    save.await;
+
+    assert!(
+        formatting_called.load(atomic::Ordering::SeqCst),
+        "ModificationsIfAvailable must fall back to full-buffer formatting when no git diff is available"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_lsp_no_range_support_falls_back_to_full_format(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    let head_content = "line0\nline1\nline2\n";
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\n", window, cx);
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when LSP lacks range support");
+        })
+        .next();
+
+    let formatting_called = Arc::new(AtomicBool::new(false));
+    let formatting_called_clone = formatting_called.clone();
+    let mut formatting_rx =
+        fake_server.set_request_handler::(move |_, _| {
+            formatting_called_clone.store(true, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    formatting_rx.next().await;
+    save.await;
+
+    assert!(
+        formatting_called.load(atomic::Ordering::SeqCst),
+        "ModificationsIfAvailable must fall back to full-file formatting when the LSP lacks range support"
+    );
+}
+
 #[gpui::test]
 async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) {
     let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
@@ -15865,6 +17778,178 @@ async fn test_formatter_failure_does_not_abort_subsequent_formatters(cx: &mut Te
     });
 }
 
+#[gpui::test]
+async fn test_explicit_formatter_failure_is_recorded(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.formatter = Some(FormatterList::Vec(vec![Formatter::LanguageServer(
+            settings::LanguageServerFormatterSpecifier::Current,
+        )]))
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_file(path!("/file.rs"), "fn main() {}\n".into())
+        .await;
+
+    let project = Project::test(fs, [path!("/").as_ref()], cx).await;
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let format_should_fail = Arc::new(AtomicBool::new(true));
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            initializer: Some(Box::new({
+                let format_should_fail = format_should_fail.clone();
+                move |fake_server| {
+                    let format_should_fail = format_should_fail.clone();
+                    fake_server.set_request_handler::(
+                        move |_params, _| {
+                            let format_should_fail = format_should_fail.clone();
+                            async move {
+                                if format_should_fail.load(atomic::Ordering::Acquire) {
+                                    Err(anyhow::anyhow!("Simulated formatter failure"))
+                                } else {
+                                    Ok(Some(Vec::new()))
+                                }
+                            }
+                        },
+                    );
+                }
+            })),
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+
+    fake_servers.next().await.unwrap();
+
+    let format = |editor: &Entity, cx: &mut VisualTestContext| {
+        editor
+            .update_in(cx, |editor, window, cx| {
+                editor.perform_format(
+                    project.clone(),
+                    FormatTrigger::Manual,
+                    FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
+                    window,
+                    cx,
+                )
+            })
+            .unwrap()
+    };
+    let last_failure = |cx: &mut VisualTestContext| {
+        project.read_with(cx, |project, cx| {
+            project
+                .lsp_store()
+                .read(cx)
+                .last_formatting_failure()
+                .map(str::to_string)
+        })
+    };
+
+    format(&editor, cx).await;
+    assert!(
+        last_failure(cx).is_some(),
+        "a failing explicitly configured formatter should be recorded"
+    );
+
+    format_should_fail.store(false, atomic::Ordering::Release);
+    format(&editor, cx).await;
+    assert_eq!(
+        last_failure(cx),
+        None,
+        "A later successful format clears the recorded failure"
+    );
+}
+
+#[gpui::test]
+async fn test_auto_formatter_failure_is_silent(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        // `formatter: "auto"` with prettier disallowed resolves to the primary language server.
+        settings.defaults.formatter = Some(FormatterList::Single(Formatter::Auto));
+        settings.defaults.prettier.get_or_insert_default().allowed = Some(false);
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_file(path!("/file.rs"), "fn main() {}\n".into())
+        .await;
+
+    let project = Project::test(fs, [path!("/").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            initializer: Some(Box::new(|fake_server| {
+                fake_server.set_request_handler::(
+                    move |_params, _| async move {
+                        Err(anyhow::anyhow!("Simulated formatter failure"))
+                    },
+                );
+            })),
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+
+    fake_servers.next().await.unwrap();
+
+    editor
+        .update_in(cx, |editor, window, cx| {
+            editor.perform_format(
+                project.clone(),
+                FormatTrigger::Manual,
+                FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
+                window,
+                cx,
+            )
+        })
+        .unwrap()
+        .await;
+
+    let last_failure = project.read_with(cx, |project, cx| {
+        project
+            .lsp_store()
+            .read(cx)
+            .last_formatting_failure()
+            .map(str::to_string)
+    });
+    assert_eq!(
+        last_failure, None,
+        "an auto-resolved formatter failure should not be surfaced"
+    );
+}
+
 #[gpui::test]
 async fn test_concurrent_format_requests(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -17675,6 +19760,202 @@ async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppConte
     })
 }
 
+#[gpui::test]
+async fn test_completion_in_multibuffer_with_newest_selection_in_other_buffer(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let main_text = indoc! {"
+        fn main() {
+            10.satu
+        }
+    "};
+    let other_text = indoc! {"
+        const VALUE: u32 = 0;
+    "};
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/a"),
+        json!({
+            "main.rs": main_text,
+            "other.rs": other_text,
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/a").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 {
+            capabilities: lsp::ServerCapabilities {
+                completion_provider: Some(lsp::CompletionOptions {
+                    resolve_provider: None,
+                    ..lsp::CompletionOptions::default()
+                }),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+    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 cx = &mut VisualTestContext::from_window(*window, cx);
+    let main_buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/a/main.rs"), cx)
+        })
+        .await
+        .unwrap();
+    let other_buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/a/other.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    let multi_buffer = cx.new(|cx| {
+        let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite);
+        multi_buffer.set_excerpts_for_path(
+            PathKey::sorted(0),
+            main_buffer.clone(),
+            [Point::zero()..main_buffer.read(cx).max_point()],
+            0,
+            cx,
+        );
+        multi_buffer.set_excerpts_for_path(
+            PathKey::sorted(1),
+            other_buffer.clone(),
+            [Point::zero()..other_buffer.read(cx).max_point()],
+            0,
+            cx,
+        );
+        multi_buffer
+    });
+
+    let editor = workspace.update_in(cx, |_, window, cx| {
+        cx.new(|cx| {
+            Editor::new(
+                EditorMode::Full {
+                    scale_ui_elements_with_buffer_font_size: false,
+                    show_active_line_background: false,
+                    sizing_behavior: SizingBehavior::Default,
+                },
+                multi_buffer.clone(),
+                Some(project.clone()),
+                window,
+                cx,
+            )
+        })
+    });
+
+    let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone());
+    pane.update_in(cx, |pane, window, cx| {
+        pane.add_item(Box::new(editor.clone()), true, true, None, window, cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+    cx.run_until_parked();
+
+    // Place the (only, and therefore newest) selection in `main.rs`, right after `satu`.
+    let main_cursor = editor.update(cx, |editor, cx| {
+        let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx);
+        let main_snapshot = main_buffer.read(cx).snapshot();
+        let anchor = main_snapshot.anchor_before(Point::new(1, 11));
+        multibuffer_snapshot
+            .buffer_anchor_range_to_anchor_range(anchor..anchor)
+            .expect("main.rs cursor position should map into the multibuffer")
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_anchor_ranges([main_cursor.clone()]);
+        });
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.show_completions(&ShowCompletions, window, cx);
+    });
+
+    fake_server
+        .set_request_handler::(move |_, _| async move {
+            let completion_item = lsp::CompletionItem {
+                label: "saturating_sub()".into(),
+                text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace(
+                    lsp::InsertReplaceEdit {
+                        new_text: "saturating_sub()".to_owned(),
+                        insert: lsp::Range::new(
+                            lsp::Position::new(1, 7),
+                            lsp::Position::new(1, 11),
+                        ),
+                        replace: lsp::Range::new(
+                            lsp::Position::new(1, 7),
+                            lsp::Position::new(1, 11),
+                        ),
+                    },
+                )),
+                ..lsp::CompletionItem::default()
+            };
+
+            Ok(Some(lsp::CompletionResponse::Array(vec![completion_item])))
+        })
+        .next()
+        .await
+        .unwrap();
+
+    cx.condition(&editor, |editor, _| editor.context_menu_visible())
+        .await;
+
+    // Move the newest selection into `other.rs` while keeping the completions menu
+    // open.
+    let other_cursor = editor.update(cx, |editor, cx| {
+        let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx);
+        let other_snapshot = other_buffer.read(cx).snapshot();
+        let anchor = other_snapshot.anchor_before(Point::new(0, 6));
+        multibuffer_snapshot
+            .buffer_anchor_range_to_anchor_range(anchor..anchor)
+            .expect("other.rs cursor position should map into the multibuffer")
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(
+            SelectionEffects::no_scroll().completions(false),
+            window,
+            cx,
+            |s| s.select_anchor_ranges([other_cursor.clone()]),
+        );
+        assert!(
+            editor.context_menu_visible(),
+            "the completions menu should still be open after moving the cursor"
+        );
+    });
+
+    // This used to panic with `invalid anchor - buffer id does not match`.
+    editor
+        .update_in(cx, |editor, window, cx| {
+            editor
+                .confirm_completion_replace(&ConfirmCompletionReplace, window, cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+
+    // The completion targets `main.rs`, so it should still be applied there.
+    main_buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            indoc! {"
+                fn main() {
+                    10.saturating_sub()
+                }
+            "}
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_completion(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -21922,6 +24203,166 @@ async fn test_completions_with_additional_edits(cx: &mut TestAppContext) {
     cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }");
 }
 
+async fn check_completion_additional_edits(
+    initial_state: &str,
+    keystroke: &str,
+    completion_item: lsp::CompletionItem,
+    state_after_primary_edit: &str,
+    state_after_additional_edits: &str,
+    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(initial_state);
+
+    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,
+            ])))
+        }
+    });
+
+    cx.simulate_keystroke(keystroke);
+    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(state_after_primary_edit);
+
+    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(state_after_additional_edits);
+}
+
+// rust-analyzer's ref-match completions (`&some_str`) deliver the `&` as a
+// zero-width additionalTextEdit at the primary edit's start.
+// Ref: https://github.com/zed-industries/zed/issues/56973
+#[gpui::test]
+async fn test_completions_with_zero_width_additional_edit_at_primary_edit_start(
+    cx: &mut TestAppContext,
+) {
+    check_completion_additional_edits(
+        // The completion must not start at (0, 0), so that this case is
+        // distinct from the file-start auto-import test below.
+        "bar(fˇ)",
+        "o",
+        lsp::CompletionItem {
+            label: "&foo".to_string(),
+            filter_text: Some("foo".to_string()),
+            // Replaces the typed `fo` with `foo`; the committed range is 4..7.
+            text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 6)),
+                new_text: "foo".to_string(),
+            })),
+            // Zero-width insertion exactly at the committed range's start.
+            additional_text_edits: Some(vec![lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)),
+                new_text: "&".to_string(),
+            }]),
+            ..Default::default()
+        },
+        "bar(fooˇ)",
+        "bar(&fooˇ)",
+        cx,
+    )
+    .await;
+}
+
+// Additional edits which overlap the primary completion edit must be skipped
+// while non-overlapping edits from the same completion are still applied.
+// Ref: https://github.com/zed-industries/zed/pull/1871
+#[gpui::test]
+async fn test_completions_skip_additional_edits_overlapping_primary_edit(cx: &mut TestAppContext) {
+    check_completion_additional_edits(
+        "fˇ\nbar",
+        "o",
+        lsp::CompletionItem {
+            label: "foo".to_string(),
+            filter_text: Some("foo".to_string()),
+            // Replaces the typed `fo` with `foo`; the committed range is 0..3.
+            text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 2)),
+                new_text: "foo".to_string(),
+            })),
+            additional_text_edits: Some(vec![
+                // Skip text strictly inside the committed range.
+                lsp::TextEdit {
+                    range: lsp::Range::new(lsp::Position::new(0, 1), lsp::Position::new(0, 2)),
+                    new_text: "XXX".to_string(),
+                },
+                // Apply text which does not touch the committed range.
+                lsp::TextEdit {
+                    range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)),
+                    new_text: "baz".to_string(),
+                },
+            ]),
+            ..Default::default()
+        },
+        "fooˇ\nbar",
+        "fooˇ\nbaz",
+        cx,
+    )
+    .await;
+}
+
+// When both the primary completion edit and an additional edit (auto-import)
+// start at the very beginning of the file, the additional edit must not be
+// treated as overlapping. This payload shape matches what
+// typescript-language-server actually sends for auto-imports at file start.
+// Ref: https://github.com/zed-industries/zed/issues/26136
+#[gpui::test]
+async fn test_completions_with_file_start_auto_import_additional_edit(cx: &mut TestAppContext) {
+    check_completion_additional_edits(
+        "ˇ",
+        "f",
+        lsp::CompletionItem {
+            label: "foo".to_string(),
+            // Replaces the typed `f` at file start; the committed range is 0..3.
+            text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)),
+                new_text: "foo".to_string(),
+            })),
+            // Auto-import inserted at the very start of the file.
+            additional_text_edits: Some(vec![lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
+                new_text: "bar\n".to_string(),
+            }]),
+            ..Default::default()
+        },
+        "fooˇ",
+        "bar\nfooˇ",
+        cx,
+    )
+    .await;
+}
+
 #[gpui::test]
 async fn test_completions_with_additional_edits_undo(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -24216,6 +26657,57 @@ async fn test_diff_base_change_with_expanded_diff_hunks(
     );
 }
 
+#[gpui::test]
+async fn test_go_to_singleton_buffer_point_with_expanded_deleted_hunks(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        removed line 1
+        removed line 2
+        line 1
+        line 2
+        line 3
+        "#
+    .unindent();
+
+    cx.set_state(
+        &r#"
+        ˇline 1
+        line 2
+        line 3
+        "#
+        .unindent(),
+    );
+
+    cx.set_head_text(&diff_base);
+    executor.run_until_parked();
+
+    cx.update_editor(|editor, window, cx| {
+        editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
+    });
+    executor.run_until_parked();
+
+    cx.update_editor(|editor, window, cx| {
+        editor.go_to_singleton_buffer_point(Point::new(2, 0), window, cx);
+    });
+
+    cx.assert_state_with_diff(
+        r#"
+        - removed line 1
+        - removed line 2
+          line 1
+          line 2
+          ˇline 3
+        "#
+        .unindent(),
+    );
+}
+
 #[gpui::test]
 async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -25133,7 +27625,13 @@ async fn setup_indent_guides_editor(
     text: &str,
     cx: &mut TestAppContext,
 ) -> (BufferId, EditorTestContext) {
-    init_test(cx, |_| {});
+    init_test(cx, |settings| {
+        settings
+            .defaults
+            .indent_guides
+            .get_or_insert_default()
+            .enabled = Some(true);
+    });
 
     let mut cx = EditorTestContext::new(cx).await;
 
@@ -25778,7 +28276,13 @@ async fn test_active_indent_guide_non_matching_indent(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_indent_guide_with_expanded_diff_hunks(cx: &mut TestAppContext) {
-    init_test(cx, |_| {});
+    init_test(cx, |settings| {
+        settings
+            .defaults
+            .indent_guides
+            .get_or_insert_default()
+            .enabled = Some(true);
+    });
     let mut cx = EditorTestContext::new(cx).await;
     let text = indoc! {
         "
@@ -26604,7 +29108,9 @@ async fn test_goto_definition_with_find_all_references_fallback(cx: &mut TestApp
     );
     set_up_lsp_handlers(false, &mut cx);
     let navigated = cx
-        .update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
+        .update_editor(|editor, window, cx| {
+            editor.go_to_definition(&GoToDefinition::default(), window, cx)
+        })
         .await
         .expect("Failed to navigate to definition");
     assert_eq!(
@@ -26638,7 +29144,9 @@ async fn test_goto_definition_with_find_all_references_fallback(cx: &mut TestApp
 
     set_up_lsp_handlers(true, &mut cx);
     let navigated = cx
-        .update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
+        .update_editor(|editor, window, cx| {
+            editor.go_to_definition(&GoToDefinition::default(), window, cx)
+        })
         .await
         .expect("Failed to navigate to lookup references");
     assert_eq!(
@@ -26715,7 +29223,9 @@ async fn test_goto_definition_no_fallback(cx: &mut TestAppContext) {
         });
 
     let navigated = cx
-        .update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
+        .update_editor(|editor, window, cx| {
+            editor.go_to_definition(&GoToDefinition::default(), window, cx)
+        })
         .await
         .expect("Failed to navigate to lookup references");
     go_to_definition
@@ -26784,7 +29294,9 @@ async fn test_goto_definition_close_ranges_open_singleton(cx: &mut TestAppContex
     });
 
     let navigated = cx
-        .update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
+        .update_editor(|editor, window, cx| {
+            editor.go_to_definition(&GoToDefinition::default(), window, cx)
+        })
         .await
         .expect("Failed to navigate to definitions");
     assert_eq!(navigated, Navigated::Yes);
@@ -26868,7 +29380,9 @@ async fn test_goto_definition_far_ranges_open_multibuffer(cx: &mut TestAppContex
     });
 
     let navigated = cx
-        .update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
+        .update_editor(|editor, window, cx| {
+            editor.go_to_definition(&GoToDefinition::default(), window, cx)
+        })
         .await
         .expect("Failed to navigate to definitions");
     assert_eq!(navigated, Navigated::Yes);
@@ -26941,7 +29455,9 @@ async fn test_goto_definition_contained_ranges(cx: &mut TestAppContext) {
     });
 
     let navigated = cx
-        .update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
+        .update_editor(|editor, window, cx| {
+            editor.go_to_definition(&GoToDefinition::default(), window, cx)
+        })
         .await
         .expect("Failed to navigate to definitions");
     assert_eq!(navigated, Navigated::Yes);
@@ -27038,9 +29554,11 @@ async fn test_goto_definition_preserve_scroll_strategy(cx: &mut TestAppContext)
     cx.update_editor(|editor, window, cx| {
         editor.set_scroll_position(gpui::Point::new(0.0, caller_row - offset), window, cx);
     });
-    cx.update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
-        .await
-        .expect("Failed to navigate to definition");
+    cx.update_editor(|editor, window, cx| {
+        editor.go_to_definition(&GoToDefinition::default(), window, cx)
+    })
+    .await
+    .expect("Failed to navigate to definition");
     cx.run_until_parked();
     cx.update_editor(|editor, window, cx| {
         assert_eq!(
@@ -27071,9 +29589,11 @@ async fn test_goto_definition_preserve_scroll_strategy(cx: &mut TestAppContext)
         assert!(cursor_row >= visible_lines, "Cursor should be offscreen");
     });
 
-    cx.update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx))
-        .await
-        .expect("Failed to navigate to definition");
+    cx.update_editor(|editor, window, cx| {
+        editor.go_to_definition(&GoToDefinition::default(), window, cx)
+    })
+    .await
+    .expect("Failed to navigate to definition");
     cx.run_until_parked();
     cx.update_editor(|editor, window, cx| {
         assert_eq!(
@@ -28345,6 +30865,45 @@ fn add_log_breakpoint_at_cursor(
     );
 }
 
+#[gpui::test]
+fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let focus_handle = cx.update(|cx| cx.focus_handle());
+    let tooltip = GutterButtonTooltip {
+        primary: GutterButtonIntent::SetBreakpoint,
+        secondary: GutterButtonIntent::SetBookmark,
+        focus_handle,
+    };
+
+    let primary_intent = tooltip.active_intent(Modifiers::none());
+    assert_eq!(primary_intent, GutterButtonIntent::SetBreakpoint);
+    assert!(primary_intent.action().as_any().is::());
+
+    let secondary_intent = tooltip.active_intent(Modifiers::secondary_key());
+    assert_eq!(secondary_intent, GutterButtonIntent::SetBookmark);
+    assert!(secondary_intent.action().as_any().is::());
+
+    // When both features are enabled, the meta text advertises the
+    // modifier-click alternative.
+    let meta = tooltip.meta_text(primary_intent);
+    assert!(meta.contains("-click to add a bookmark"), "got: {meta}");
+    assert!(meta.contains("right-click for more options"));
+    let meta = tooltip.meta_text(secondary_intent);
+    assert!(meta.contains("-click to add a breakpoint"), "got: {meta}");
+
+    // When only one feature is enabled (primary == secondary), a
+    // modifier-click repeats the primary action, so the tooltip must not
+    // advertise it.
+    let single_feature_tooltip = GutterButtonTooltip {
+        primary: GutterButtonIntent::SetBreakpoint,
+        secondary: GutterButtonIntent::SetBreakpoint,
+        focus_handle: tooltip.focus_handle,
+    };
+    let meta = single_feature_tooltip.meta_text(GutterButtonIntent::SetBreakpoint);
+    assert_eq!(meta, "right-click for more options");
+}
+
 #[gpui::test]
 async fn test_breakpoint_toggling(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -29140,6 +31699,13 @@ impl BookmarkTestContext {
             });
     }
 
+    fn toggle_bookmark_with_label(&mut self) {
+        self.editor
+            .update_in(&mut self.cx, |editor: &mut Editor, window, cx| {
+                editor.toggle_bookmark_with_label(&actions::ToggleBookmarkWithLabel, window, cx);
+            });
+    }
+
     fn confirm_bookmark_prompt(&mut self, label: &str) {
         if !label.is_empty() {
             self.cx.simulate_input(label);
@@ -29149,7 +31715,7 @@ impl BookmarkTestContext {
     }
 
     fn add_bookmark_with_label(&mut self, label: &str) {
-        self.toggle_bookmark();
+        self.toggle_bookmark_with_label();
         self.confirm_bookmark_prompt(label);
     }
 
@@ -29204,13 +31770,39 @@ async fn test_bookmark_toggling(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext) {
+async fn test_bookmark_toggling_unnamed_with_multiple_selections(cx: &mut TestAppContext) {
     let mut ctx =
         BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await;
 
     ctx.select_rows(&[0, 1, 2]);
     ctx.toggle_bookmark();
 
+    ctx.assert_prompt_block_count(0);
+    ctx.assert_bookmarked_file_count(1);
+    ctx.assert_bookmark_labels(vec![(0, ""), (1, ""), (2, "")]);
+
+    ctx.select_rows(&[0, 1, 2, 3]);
+    ctx.toggle_bookmark();
+
+    ctx.assert_prompt_block_count(0);
+    ctx.assert_bookmark_rows(vec![0, 1, 2, 3]);
+
+    ctx.select_rows(&[0, 1, 2, 3]);
+    ctx.toggle_bookmark();
+
+    ctx.assert_prompt_block_count(0);
+    ctx.assert_bookmarked_file_count(0);
+    ctx.assert_bookmark_rows(vec![]);
+}
+
+#[gpui::test]
+async fn test_bookmark_toggling_with_label_with_multiple_selections(cx: &mut TestAppContext) {
+    let mut ctx =
+        BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await;
+
+    ctx.select_rows(&[0, 1, 2]);
+    ctx.toggle_bookmark_with_label();
+
     ctx.assert_prompt_block_count(3);
     ctx.assert_bookmarked_file_count(0);
 
@@ -29229,7 +31821,7 @@ async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext
     ]);
 
     ctx.select_rows(&[0, 1, 2, 3]);
-    ctx.toggle_bookmark();
+    ctx.toggle_bookmark_with_label();
 
     ctx.assert_prompt_block_count(1);
     ctx.assert_bookmark_labels(vec![
@@ -29249,7 +31841,7 @@ async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext
     ]);
 
     ctx.select_rows(&[0, 1, 2, 3]);
-    ctx.toggle_bookmark();
+    ctx.toggle_bookmark_with_label();
 
     ctx.assert_prompt_block_count(0);
     ctx.assert_bookmarked_file_count(0);
@@ -32412,6 +35004,20 @@ pub(crate) fn update_test_editor_settings(
     })
 }
 
+/// Pins the buffer line height to the value the upstream test expectations
+/// were computed against (the buffer font itself is already pinned by
+/// `settings::test_settings`), for tests whose assertions depend on how many
+/// rows fit into a window of a fixed pixel size.
+pub(crate) fn pin_upstream_buffer_font_metrics(cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.theme.buffer_line_height = Some(settings::BufferLineHeight::Comfortable);
+            });
+        })
+    })
+}
+
 pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) {
     cx.update(|cx| {
         assets::Assets.load_test_fonts(cx);
@@ -33720,6 +36326,273 @@ async fn test_paste_url_from_other_app_creates_markdown_link_selectively_in_mult
     ));
 }
 
+#[gpui::test]
+async fn test_paste_image_in_markdown_saves_file_and_inserts_markdown(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let markdown_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "Markdown".into(),
+            ..LanguageConfig::default()
+        },
+        None,
+    ));
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/test", serde_json::json!({"test.md": ""}))
+        .await;
+    let project = Project::test(fs.clone(), [std::path::Path::new("/test")], cx).await;
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer("/test/test.md", cx)
+        })
+        .await
+        .unwrap();
+    buffer.update(cx, |buffer, cx| {
+        buffer.set_language(Some(markdown_language), cx);
+    });
+
+    let editor_window = cx.add_window(|window, cx| {
+        let editor = build_editor_with_project(
+            project,
+            MultiBuffer::build_from_buffer(buffer, cx),
+            window,
+            cx,
+        );
+        window.focus(&editor.focus_handle(cx), cx);
+        editor
+    });
+    cx.run_until_parked();
+    let mut cx = EditorTestContext::for_editor(editor_window, cx).await;
+
+    let png_bytes: Vec = vec![1, 2, 3, 4, 5];
+    let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes.clone());
+
+    cx.set_state("ˇ");
+    cx.update_editor(|editor, window, cx| {
+        cx.write_to_clipboard(ClipboardItem::new_image(&image));
+        editor.paste(&Paste, window, cx);
+    });
+    cx.run_until_parked();
+
+    let buffer_text = cx.buffer_text();
+    assert!(
+        buffer_text.starts_with("![](image") && buffer_text.ends_with(".png)"),
+        "expected markdown image syntax, got: {buffer_text:?}"
+    );
+
+    // Cursor should land inside [] so the user can type alt text immediately.
+    let filename_in_parens = &buffer_text["![](".len()..buffer_text.len() - 1];
+    let expected_state = format!("![ˇ]({filename_in_parens})");
+    cx.assert_editor_state(&expected_state);
+
+    let filename = &buffer_text["![](".len()..buffer_text.len() - 1];
+    assert_eq!(
+        fs.read_file_sync(format!("/test/{filename}")).unwrap(),
+        png_bytes,
+        "image file contents should match clipboard bytes"
+    );
+}
+
+#[gpui::test]
+async fn test_paste_multiple_images_in_markdown_increments_filename(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let markdown_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "Markdown".into(),
+            ..LanguageConfig::default()
+        },
+        None,
+    ));
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/test", serde_json::json!({"test.md": ""}))
+        .await;
+    let project = Project::test(fs.clone(), [std::path::Path::new("/test")], cx).await;
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer("/test/test.md", cx)
+        })
+        .await
+        .unwrap();
+    buffer.update(cx, |buffer, cx| {
+        buffer.set_language(Some(markdown_language), cx);
+    });
+
+    let editor_window = cx.add_window(|window, cx| {
+        let editor = build_editor_with_project(
+            project,
+            MultiBuffer::build_from_buffer(buffer, cx),
+            window,
+            cx,
+        );
+        window.focus(&editor.focus_handle(cx), cx);
+        editor
+    });
+    cx.run_until_parked();
+    let mut cx = EditorTestContext::for_editor(editor_window, cx).await;
+
+    let png_bytes_1: Vec = vec![1, 2, 3, 4, 5];
+    let png_bytes_2: Vec = vec![6, 7, 8, 9, 10];
+    let png_bytes_3: Vec = vec![11, 12, 13, 14, 15];
+    let image_1 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_1.clone());
+    let image_2 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_2.clone());
+    let image_3 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_3.clone());
+
+    // Paste first image — should produce image.png
+    cx.set_state("ˇ");
+    cx.update_editor(|editor, window, cx| {
+        cx.write_to_clipboard(ClipboardItem::new_image(&image_1));
+        editor.paste(&Paste, window, cx);
+    });
+    cx.run_until_parked();
+    let text_after_first = cx.buffer_text();
+    assert_eq!(
+        text_after_first, "![](image.png)",
+        "first paste should produce image.png"
+    );
+    assert_eq!(fs.read_file_sync("/test/image.png").unwrap(), png_bytes_1);
+
+    // Paste second image at end — should produce image_1.png
+    cx.update_editor(|editor, window, cx| {
+        cx.write_to_clipboard(ClipboardItem::new_image(&image_2));
+        editor.paste(&Paste, window, cx);
+    });
+    cx.run_until_parked();
+    let text_after_second = cx.buffer_text();
+    assert!(
+        text_after_second.contains("![](image_1.png)"),
+        "second paste should produce image_1.png, got: {text_after_second:?}"
+    );
+    assert_eq!(fs.read_file_sync("/test/image_1.png").unwrap(), png_bytes_2);
+
+    // Paste third image — should produce image_2.png
+    cx.update_editor(|editor, window, cx| {
+        cx.write_to_clipboard(ClipboardItem::new_image(&image_3));
+        editor.paste(&Paste, window, cx);
+    });
+    cx.run_until_parked();
+    let text_after_third = cx.buffer_text();
+    assert!(
+        text_after_third.contains("![](image_2.png)"),
+        "third paste should produce image_2.png, got: {text_after_third:?}"
+    );
+    assert_eq!(fs.read_file_sync("/test/image_2.png").unwrap(), png_bytes_3);
+}
+
+#[gpui::test]
+async fn test_paste_image_in_non_markdown_does_not_insert_markdown(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let rust_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            ..LanguageConfig::default()
+        },
+        None,
+    ));
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_language), cx));
+    cx.set_state("ˇ");
+
+    let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]);
+    cx.update_editor(|editor, window, cx| {
+        cx.write_to_clipboard(ClipboardItem::new_image(&image));
+        editor.paste(&Paste, window, cx);
+    });
+
+    cx.assert_editor_state("ˇ");
+}
+
+#[gpui::test]
+async fn test_paste_image_in_markdown_without_open_file_falls_through(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let markdown_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "Markdown".into(),
+            ..LanguageConfig::default()
+        },
+        None,
+    ));
+
+    // Build an editor whose buffer has no associated file path on disk.
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        let buffer = cx.new(|cx| language::Buffer::local("", cx));
+        buffer.update(cx, |buffer, cx| {
+            buffer.set_language(Some(markdown_language), cx);
+        });
+        let multibuffer = MultiBuffer::build_from_buffer(buffer, cx);
+        build_editor(multibuffer, window, cx)
+    });
+    let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
+
+    cx.set_state("ˇ");
+    let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]);
+    cx.update_editor(|editor, window, cx| {
+        cx.write_to_clipboard(ClipboardItem::new_image(&image));
+        editor.paste(&Paste, window, cx);
+    });
+
+    cx.assert_editor_state("ˇ");
+}
+
+#[gpui::test]
+async fn test_paste_image_in_markdown_single_file_worktree_falls_through(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let markdown_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "Markdown".into(),
+            ..LanguageConfig::default()
+        },
+        None,
+    ));
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root", serde_json::json!({"test.md": ""}))
+        .await;
+    let project = Project::test(fs.clone(), [std::path::Path::new("/root/test.md")], cx).await;
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer("/root/test.md", cx)
+        })
+        .await
+        .unwrap();
+    buffer.update(cx, |buffer, cx| {
+        buffer.set_language(Some(markdown_language), cx);
+    });
+
+    let editor_window = cx.add_window(|window, cx| {
+        let editor = build_editor_with_project(
+            project,
+            MultiBuffer::build_from_buffer(buffer, cx),
+            window,
+            cx,
+        );
+        window.focus(&editor.focus_handle(cx), cx);
+        editor
+    });
+    cx.run_until_parked();
+    let mut cx = EditorTestContext::for_editor(editor_window, cx).await;
+
+    cx.set_state("ˇ");
+    let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]);
+    cx.update_editor(|editor, window, cx| {
+        cx.write_to_clipboard(ClipboardItem::new_image(&image));
+        editor.paste(&Paste, window, cx);
+    });
+    cx.run_until_parked();
+
+    cx.assert_editor_state("ˇ");
+    assert!(
+        fs.read_file_sync("/root/image.png").is_err(),
+        "no image file should be created for a single-file worktree"
+    );
+}
+
 #[gpui::test]
 async fn test_race_in_multibuffer_save(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -35282,6 +38155,7 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
 
     let action = FindAllReferences {
         always_open_multibuffer: false,
+        open_results_in: None,
     };
 
     let navigated = cx
@@ -35297,6 +38171,116 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
     cx.assert_editor_state(after);
 }
 
+/// Maps each resolved [`Location`] to `(row, start_column, end_column)`.
+fn location_row_columns(
+    cx: &mut EditorLspTestContext,
+    locations: &[project::Location],
+) -> Vec<(u32, u32, u32)> {
+    let mut rows = cx.update_editor(|_editor, _window, cx| {
+        locations
+            .iter()
+            .map(|location| {
+                let snapshot = location.buffer.read(cx).snapshot();
+                let start: usize = snapshot.summary_for_anchor(&location.range.start);
+                let end: usize = snapshot.summary_for_anchor(&location.range.end);
+                let start = snapshot.offset_to_point(start);
+                let end = snapshot.offset_to_point(end);
+                (start.row, start.column, end.column)
+            })
+            .collect::>()
+    });
+    rows.sort();
+    rows
+}
+
+#[gpui::test]
+async fn test_definition_locations_of_kind_excludes_self_link(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;
+
+    // Cursor sits inside the `abc` use on row 2 (columns 14..17).
+    cx.set_state(indoc!(
+        r#"
+        fn main() {
+            let abc = 123;
+            let xyz = aˇbc;
+        }
+        "#
+    ));
+
+    // The server returns two targets: the location covering the cursor (which
+    // must be filtered out as a self-link) and the real definition on row 1.
+    cx.lsp
+        .set_request_handler::(async move |params, _| {
+            let uri = params.text_document_position_params.text_document.uri;
+            Ok(Some(lsp::GotoDefinitionResponse::Array(vec![
+                lsp::Location {
+                    uri: uri.clone(),
+                    range: lsp::Range::new(lsp::Position::new(2, 14), lsp::Position::new(2, 17)),
+                },
+                lsp::Location {
+                    uri,
+                    range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 11)),
+                },
+            ])))
+        });
+
+    let locations = cx
+        .update_editor(|editor, _window, cx| {
+            editor.definition_locations_of_kind(GotoDefinitionKind::Symbol, cx)
+        })
+        .expect("definition query should spawn a task")
+        .await
+        .unwrap();
+
+    // Only the real definition remains; the target covering the cursor is dropped.
+    assert_eq!(location_row_columns(&mut cx, &locations), vec![(1, 8, 11)]);
+}
+
+#[test]
+fn test_open_results_in_action_argument_parsing() {
+    // A bare keybinding (no arguments) resolves to `None`, i.e. defer to the
+    // `lsp_results_location` setting. Keymap loading builds actions from `{}`
+    // when no arguments are given, so that is what we deserialize here.
+    assert_eq!(
+        serde_json::from_value::(json!({}))
+            .unwrap()
+            .open_results_in,
+        None,
+    );
+
+    // The `OpenResultsIn` variants must keep the snake_case spelling that
+    // keymaps and the `lsp_results_location` setting rely on; a rename here
+    // would silently break those keybindings.
+    assert_eq!(
+        serde_json::from_value::(json!({ "open_results_in": "picker" }))
+            .unwrap()
+            .open_results_in,
+        Some(OpenResultsIn::Picker),
+    );
+    assert_eq!(
+        serde_json::from_value::(json!({ "open_results_in": "multi_buffer" }))
+            .unwrap()
+            .open_results_in,
+        Some(OpenResultsIn::MultiBuffer),
+    );
+
+    // The argument coexists with `FindAllReferences`'s existing field, which
+    // keeps its own default when only `open_results_in` is provided.
+    let references =
+        serde_json::from_value::(json!({ "open_results_in": "picker" }))
+            .unwrap();
+    assert_eq!(references.open_results_in, Some(OpenResultsIn::Picker));
+    assert!(references.always_open_multibuffer);
+}
+
 #[gpui::test]
 async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
     init_test(cx, |settings| {
@@ -36241,7 +39225,7 @@ fn test_hunk_key(file_path: &str) -> DiffHunkKey {
         file_path: if file_path.is_empty() {
             Arc::from(util::rel_path::RelPath::empty())
         } else {
-            Arc::from(util::rel_path::RelPath::unix(file_path).unwrap())
+            Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap())
         },
         hunk_start_anchor: Anchor::Min,
     }
@@ -36253,7 +39237,7 @@ fn test_hunk_key_with_anchor(file_path: &str, anchor: Anchor) -> DiffHunkKey {
         file_path: if file_path.is_empty() {
             Arc::from(util::rel_path::RelPath::empty())
         } else {
-            Arc::from(util::rel_path::RelPath::unix(file_path).unwrap())
+            Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap())
         },
         hunk_start_anchor: anchor,
     }
@@ -36692,11 +39676,11 @@ fn test_comments_stored_for_multiple_hunks(cx: &mut TestAppContext) {
         // Create two different hunk keys (simulating two different files)
         let anchor = snapshot.anchor_before(Point::new(0, 0));
         let key1 = DiffHunkKey {
-            file_path: Arc::from(util::rel_path::RelPath::unix("file1.rs").unwrap()),
+            file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file1.rs").unwrap()),
             hunk_start_anchor: anchor,
         };
         let key2 = DiffHunkKey {
-            file_path: Arc::from(util::rel_path::RelPath::unix("file2.rs").unwrap()),
+            file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file2.rs").unwrap()),
             hunk_start_anchor: anchor,
         };
 
@@ -36764,11 +39748,11 @@ fn test_same_hunk_detected_by_matching_keys(cx: &mut TestAppContext) {
 
         // Create two keys with the same file path and anchor
         let key1 = DiffHunkKey {
-            file_path: Arc::from(util::rel_path::RelPath::unix("file.rs").unwrap()),
+            file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()),
             hunk_start_anchor: anchor,
         };
         let key2 = DiffHunkKey {
-            file_path: Arc::from(util::rel_path::RelPath::unix("file.rs").unwrap()),
+            file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()),
             hunk_start_anchor: anchor,
         };
 
@@ -36785,7 +39769,7 @@ fn test_same_hunk_detected_by_matching_keys(cx: &mut TestAppContext) {
 
         // Create a key with different file path
         let different_file_key = DiffHunkKey {
-            file_path: Arc::from(util::rel_path::RelPath::unix("other.rs").unwrap()),
+            file_path: Arc::from(util::rel_path::RelPath::from_unix_str("other.rs").unwrap()),
             hunk_start_anchor: anchor,
         };
 
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index e8402c286444ba..44e6fe610daa93 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -3,6 +3,7 @@ mod mouse;
 
 #[cfg(test)]
 pub(crate) use header::StickyHeader;
+pub use header::file_status_label_color;
 pub(crate) use header::{header_jump_data, render_buffer_header};
 
 use crate::{
@@ -58,7 +59,7 @@ use language::{
 use markdown::Markdown;
 use multi_buffer::{
     Anchor, ExpandExcerptDirection, ExpandInfo, MultiBufferOffset, MultiBufferPoint,
-    MultiBufferRow, RowInfo,
+    MultiBufferRow, RowInfo, ToOffset,
 };
 
 use project::{
@@ -166,10 +167,28 @@ impl SelectionLayout {
         is_local: bool,
         user_name: Option,
     ) -> Self {
-        let point_selection = selection.map(|p| p.to_point(map.buffer_snapshot()));
+        let buffer_snapshot = map.buffer_snapshot();
+        let point_selection = selection.map(|p| p.to_point(buffer_snapshot));
         let display_selection = point_selection.map(|p| p.to_display_point(map));
         let mut range = display_selection.range();
         let mut head = display_selection.head();
+        if !line_mode {
+            let offset_range = point_selection.start.to_offset(buffer_snapshot)
+                ..point_selection.end.to_offset(buffer_snapshot);
+            if let Some(contiguous_range) =
+                map.contiguous_display_point_range_for_buffer_range(offset_range)
+            {
+                range = contiguous_range;
+                // Keep the cursor attached to the highlight boundary; the
+                // anchor-bias display position may sit on the far side of a
+                // boundary inlay the highlight excludes.
+                head = if selection.reversed {
+                    range.start
+                } else {
+                    range.end
+                };
+            }
+        }
         let mut active_rows = map.prev_line_boundary(point_selection.start).1.row()
             ..map.next_line_boundary(point_selection.end).1.row();
 
@@ -183,7 +202,7 @@ impl SelectionLayout {
         if cursor_offset && !range.is_empty() && !selection.reversed {
             if head.column() > 0 {
                 head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left);
-            } else if head.row().0 > 0 && head != map.max_point() {
+            } else if head.row().0 > 0 {
                 head = map.clip_point(
                     DisplayPoint::new(
                         head.row().previous_row(),
@@ -284,22 +303,22 @@ impl EditorElement {
         register_action(editor, window, Editor::scroll_cursor_bottom);
         register_action(editor, window, Editor::scroll_cursor_center_top_bottom);
         register_action(editor, window, |editor, _: &LineDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Line(1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx)
         });
         register_action(editor, window, |editor, _: &LineUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Line(-1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx)
         });
         register_action(editor, window, |editor, _: &HalfPageDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx)
         });
         register_action(editor, window, |editor, _: &HalfPageUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx)
         });
         register_action(editor, window, |editor, _: &PageDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(1.), window, cx)
         });
         register_action(editor, window, |editor, _: &PageUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-1.), window, cx)
         });
         register_action(editor, window, Editor::move_to_previous_word_start);
         register_action(editor, window, Editor::move_to_previous_subword_start);
@@ -309,6 +328,8 @@ impl EditorElement {
         register_action(editor, window, Editor::move_to_end_of_line);
         register_action(editor, window, Editor::move_to_start_of_paragraph);
         register_action(editor, window, Editor::move_to_end_of_paragraph);
+        register_action(editor, window, Editor::move_to_next_comment_paragraph);
+        register_action(editor, window, Editor::move_to_previous_comment_paragraph);
         register_action(editor, window, Editor::move_to_beginning);
         register_action(editor, window, Editor::move_to_end);
         register_action(editor, window, Editor::move_to_start_of_excerpt);
@@ -536,6 +557,7 @@ impl EditorElement {
         register_action(editor, window, Editor::spawn_nearest_task);
         register_action(editor, window, Editor::open_selections_in_multibuffer);
         register_action(editor, window, Editor::toggle_bookmark);
+        register_action(editor, window, Editor::toggle_bookmark_with_label);
         register_action(editor, window, Editor::edit_bookmark);
         register_action(editor, window, Editor::go_to_next_bookmark);
         register_action(editor, window, Editor::go_to_previous_bookmark);
@@ -2539,13 +2561,10 @@ impl EditorElement {
             return None;
         }
 
-        let buffer_id = row_info.and_then(|info| info.buffer_id);
-        if buffer_id.is_none() {
-            return None;
-        }
+        let buffer_id = row_info.and_then(|info| info.buffer_id)?;
 
         let editor = self.editor.read(cx);
-        if buffer_id.is_some_and(|buffer_id| editor.is_buffer_folded(buffer_id, cx)) {
+        if editor.is_buffer_folded(buffer_id, cx) {
             return None;
         }
 
@@ -3063,7 +3082,7 @@ impl EditorElement {
                         ..Default::default()
                     };
                     let line = window.text_system().shape_line(
-                        line.to_string().into(),
+                        SharedString::new(line),
                         font_size,
                         &[run],
                         None,
@@ -3166,13 +3185,13 @@ impl EditorElement {
                 }
                 let align_to = block_start.to_display_point(snapshot);
                 let x_and_width = |layout: &LineWithInvisibles| {
-                    Some((
+                    (
                         text_x + layout.x_for_index(align_to.column() as usize),
                         text_x + layout.width,
-                    ))
+                    )
                 };
                 let line_ix = align_to.row().0.checked_sub(rows.start.0);
-                x_position =
+                let custom_block_x_position =
                     if let Some(layout) = line_ix.and_then(|ix| line_layouts.get(ix as usize)) {
                         x_and_width(layout)
                     } else {
@@ -3187,7 +3206,8 @@ impl EditorElement {
                         ))
                     };
 
-                let anchor_x = x_position.unwrap().0;
+                let anchor_x = custom_block_x_position.0;
+                x_position = Some(custom_block_x_position);
 
                 let selected = selections
                     .binary_search_by(|selection| {
@@ -3883,7 +3903,7 @@ impl EditorElement {
                 } else {
                     None
                 };
-                vec![edit_prediction, context_menu]
+                [edit_prediction, context_menu]
                     .into_iter()
                     .flatten()
                     .collect::>()
@@ -4343,25 +4363,29 @@ impl EditorElement {
         let hovered_point = content_origin + point(x, y);
 
         let mut overall_height = Pixels::ZERO;
-        let mut measured_hover_popovers = Vec::new();
-        for (position, mut hover_popover) in hover_popovers.into_iter().with_position() {
-            let size = hover_popover.layout_as_root(AvailableSpace::min_size(), window, cx);
-            let horizontal_offset =
-                (hitbox.top_right().x - POPOVER_RIGHT_OFFSET - (hovered_point.x + size.width))
-                    .min(Pixels::ZERO);
-            match position {
-                itertools::Position::Middle | itertools::Position::Last => {
-                    overall_height += HOVER_POPOVER_GAP
+
+        let measured_hover_popovers = hover_popovers
+            .into_iter()
+            .with_position()
+            .map(|(position, mut hover_popover)| {
+                let size = hover_popover.layout_as_root(AvailableSpace::min_size(), window, cx);
+                let horizontal_offset =
+                    (hitbox.top_right().x - POPOVER_RIGHT_OFFSET - (hovered_point.x + size.width))
+                        .min(Pixels::ZERO);
+                match position {
+                    itertools::Position::Middle | itertools::Position::Last => {
+                        overall_height += HOVER_POPOVER_GAP
+                    }
+                    _ => {}
                 }
-                _ => {}
-            }
-            overall_height += size.height;
-            measured_hover_popovers.push(MeasuredHoverPopover {
-                element: hover_popover,
-                size,
-                horizontal_offset,
-            });
-        }
+                overall_height += size.height;
+                MeasuredHoverPopover {
+                    element: hover_popover,
+                    size,
+                    horizontal_offset,
+                }
+            })
+            .collect::>();
 
         fn draw_occluder(
             width: Pixels,
@@ -4431,34 +4455,28 @@ impl EditorElement {
         };
 
         let can_place_above = {
-            let mut bounds_above = Vec::new();
             let mut current_y = hovered_point.y;
-            for popover in &measured_hover_popovers {
+            measured_hover_popovers.iter().all(|popover| {
                 let size = popover.size;
                 let popover_origin = point(
                     hovered_point.x + popover.horizontal_offset,
                     current_y - size.height,
                 );
-                bounds_above.push(Bounds::new(popover_origin, size));
+                let bounds = Bounds::new(popover_origin, size);
                 current_y = popover_origin.y - HOVER_POPOVER_GAP;
-            }
-            bounds_above
-                .iter()
-                .all(|b| b.is_contained_within(hitbox) && !intersects_menu(*b))
+                bounds.is_contained_within(hitbox) && !intersects_menu(bounds)
+            })
         };
 
         let can_place_below = || {
-            let mut bounds_below = Vec::new();
             let mut current_y = hovered_point.y + line_height;
-            for popover in &measured_hover_popovers {
+            measured_hover_popovers.iter().all(|popover| {
                 let size = popover.size;
                 let popover_origin = point(hovered_point.x + popover.horizontal_offset, current_y);
-                bounds_below.push(Bounds::new(popover_origin, size));
+                let bounds = Bounds::new(popover_origin, size);
                 current_y = popover_origin.y + size.height + HOVER_POPOVER_GAP;
-            }
-            bounds_below
-                .iter()
-                .all(|b| b.is_contained_within(hitbox) && !intersects_menu(*b))
+                bounds.is_contained_within(hitbox) && !intersects_menu(bounds)
+            })
         };
 
         if can_place_above {
@@ -4486,7 +4504,7 @@ impl EditorElement {
                 } else {
                     menu.bounds.top()
                 };
-                let possible_origins = vec![
+                let possible_origins = [
                     // left of context menu
                     point(
                         menu.bounds.left() - total_width - HOVER_POPOVER_GAP,
@@ -4620,7 +4638,7 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut App,
     ) -> (Vec, Vec<(DisplayRow, Bounds)>) {
-        let render_diff_hunk_controls = editor.read(cx).render_diff_hunk_controls.clone();
+        let diff_hunk_delegate = editor.read(cx).diff_hunk_delegate();
         let hovered_diff_hunk_row = editor.read(cx).hovered_diff_hunk_row;
         let sticky_top = text_hitbox.bounds.top() + sticky_header_height;
 
@@ -4692,7 +4710,7 @@ impl EditorElement {
                         sticky_top.min(max_y)
                     };
 
-                    let mut element = render_diff_hunk_controls(
+                    let mut element = diff_hunk_delegate.render_hunk_controls(
                         display_row_range.start.0,
                         status,
                         multi_buffer_range.clone(),
@@ -4826,7 +4844,7 @@ impl EditorElement {
                 } else {
                     menu.bounds.top()
                 };
-                let possible_origins = vec![
+                let possible_origins = [
                     // left of context menu
                     point(
                         menu.bounds.left() - actual_size.width - HOVER_POPOVER_GAP,
@@ -6542,8 +6560,11 @@ impl EditorElement {
     }
 
     fn diff_hunk_hollow(&self, status: DiffHunkStatus, cx: &mut App) -> bool {
-        let unstaged =
-            self.editor.read(cx).render_diff_hunks_as_unstaged || status.has_secondary_hunk();
+        let unstaged = !self
+            .editor
+            .read(cx)
+            .diff_hunk_delegate()
+            .render_hunk_as_staged(&status, cx);
         let unstaged_hollow = matches!(
             ProjectSettings::get_global(cx).git.hunk_style,
             GitHunkStyleSetting::UnstagedHollow
@@ -6594,7 +6615,7 @@ impl EditorElement {
                             is_newest: false,
                             is_local: false,
                             active_rows: start.row()..end.row(),
-                            user_name: Some(SharedString::new(debug_range.value.clone())),
+                            user_name: Some(SharedString::from(debug_range.value.clone())),
                         };
                         Some((player_color, vec![selection_layout]))
                     })
@@ -6921,10 +6942,12 @@ fn render_blame_entry_popover(
     let renderer = cx.global::().0.clone();
     let blame = blame.read(cx);
     let repository = blame.repository(cx, buffer)?;
+    let tag_names = blame.tag_names_for_entry(buffer, &blame_entry);
     renderer.render_blame_entry_popover(
         blame_entry,
         scroll_handle,
         commit_message,
+        tag_names,
         markdown,
         repository,
         workspace,
@@ -6961,11 +6984,13 @@ fn render_blame_entry(
 
     let blame = blame.read(cx);
     let details = blame.details_for_entry(buffer, &blame_entry);
+    let tag_names = blame.tag_names_for_entry(buffer, &blame_entry);
     let repository = blame.repository(cx, buffer)?;
     renderer.render_blame_entry(
         &style.text,
         blame_entry,
         details,
+        tag_names,
         repository,
         workspace.downgrade(),
         editor,
@@ -7066,7 +7091,7 @@ impl LineWithInvisibles {
                         &Self::split_runs_by_bg_segments(&styles, segments, min_contrast, len)
                     };
                     let shaped_line = window.text_system().shape_line(
-                        line.clone().into(),
+                        line.as_str().into(),
                         font_size,
                         text_runs,
                         None,
@@ -8320,7 +8345,7 @@ impl Element for EditorElement {
                                     selected_buffer_ids
                                 };
 
-                                let mut selections = editor.selections.disjoint_in_range(
+                                let mut selections = editor.selections.disjoint_in_row_range(
                                     start_anchor..end_anchor,
                                     &snapshot.display_snapshot,
                                 );
@@ -9290,7 +9315,7 @@ impl Element for EditorElement {
                     };
 
                     let (diff_hunk_controls, diff_hunk_control_bounds) =
-                        if is_read_only && !self.editor.read(cx).delegate_stage_and_restore {
+                        if is_read_only && self.editor.read(cx).diff_hunk_delegate.is_none() {
                             (vec![], vec![])
                         } else {
                             self.layout_diff_hunk_controls(
@@ -10662,13 +10687,13 @@ fn compute_auto_height_layout(
 mod tests {
     use super::*;
     use crate::{
-        Editor, HighlightKey, MultiBuffer, NavigationOverlayKey, NavigationOverlayLabel,
-        NavigationTargetOverlay, SelectionEffects,
-        display_map::{BlockPlacement, BlockProperties},
+        Editor, FoldPlaceholder, HighlightKey, Inlay, MultiBuffer, NavigationOverlayKey,
+        NavigationOverlayLabel, NavigationTargetOverlay, SelectionEffects,
+        display_map::{BlockPlacement, BlockProperties, DisplayMap},
         editor_tests::{init_test, update_test_language_settings},
     };
-    use gpui::{TestAppContext, VisualTestContext};
-    use language::{Buffer, language_settings, tree_sitter_python};
+    use gpui::{TestAppContext, VisualTestContext, font};
+    use language::{Buffer, SelectionGoal, language_settings, tree_sitter_python};
     use log::info;
     use rand::{RngCore, rngs::StdRng};
     use std::num::NonZeroU32;
@@ -10719,7 +10744,15 @@ mod tests {
         Hitbox {
             id: HitboxId::placeholder(),
             bounds: zero_bounds,
-            content_mask: ContentMask::new(zero_bounds),
+            content_mask: ContentMask {
+                bounds: zero_bounds,
+                corner_radii: gpui::Corners {
+                    top_left: Pixels::ZERO,
+                    top_right: Pixels::ZERO,
+                    bottom_right: Pixels::ZERO,
+                    bottom_left: Pixels::ZERO,
+                },
+            },
             behavior: HitboxBehavior::Normal,
         }
     }
@@ -10769,6 +10802,121 @@ mod tests {
         }
     }
 
+    // Regression test for https://github.com/zed-industries/zed/issues/48141.
+    #[gpui::test]
+    fn test_selection_layout_around_inlay(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let snapshot = cx.update(|cx| {
+            let buffer = MultiBuffer::build_simple("abcd", cx);
+            let buffer_snapshot = buffer.read(cx).snapshot(cx);
+            let display_map = cx.new(|cx| {
+                DisplayMap::new(
+                    buffer,
+                    font("Helvetica"),
+                    px(14.0),
+                    None,
+                    1,
+                    1,
+                    FoldPlaceholder::test(),
+                    project::project_settings::DiagnosticSeverity::Warning,
+                    cx,
+                )
+            });
+            display_map.update(cx, |display_map, cx| {
+                display_map.splice_inlays(
+                    &[],
+                    vec![
+                        Inlay::mock_hint(
+                            0,
+                            buffer_snapshot.anchor_before(MultiBufferOffset(1)),
+                            "hint ",
+                        ),
+                        Inlay::mock_hint(
+                            1,
+                            buffer_snapshot.anchor_after(MultiBufferOffset(3)),
+                            "!!",
+                        ),
+                    ],
+                    cx,
+                );
+                display_map.snapshot(cx)
+            })
+        });
+
+        let layout = |range: Range, reversed, cursor_offset| {
+            SelectionLayout::new(
+                Selection {
+                    id: 0,
+                    start: range.start,
+                    end: range.end,
+                    reversed,
+                    goal: SelectionGoal::None,
+                },
+                false,
+                cursor_offset,
+                CursorShape::Bar,
+                &snapshot,
+                true,
+                true,
+                None,
+            )
+        };
+        let display_point = |column| DisplayPoint::new(DisplayRow(0), column);
+
+        let ending_at_inlay = layout(MultiBufferOffset(0)..MultiBufferOffset(1), false, false);
+        assert_eq!(ending_at_inlay.range, display_point(0)..display_point(1));
+        assert_eq!(ending_at_inlay.head, display_point(1));
+
+        let starting_at_inlay = layout(MultiBufferOffset(1)..MultiBufferOffset(2), false, false);
+        assert_eq!(starting_at_inlay.range, display_point(6)..display_point(7));
+        assert_eq!(starting_at_inlay.head, display_point(7));
+
+        let spanning_inlay = layout(MultiBufferOffset(0)..MultiBufferOffset(2), false, false);
+        assert_eq!(spanning_inlay.range, display_point(0)..display_point(7));
+        assert_eq!(spanning_inlay.head, display_point(7));
+
+        let reversed = layout(MultiBufferOffset(0)..MultiBufferOffset(2), true, false);
+        assert_eq!(reversed.range, display_point(0)..display_point(7));
+        assert_eq!(reversed.head, display_point(0));
+
+        // A reversed selection starting at a right-anchored hint: the
+        // anchor-bias cursor position would sit before the hint, detached from
+        // the highlight, so the head snaps to the highlight boundary instead.
+        let reversed_at_right_anchored_inlay =
+            layout(MultiBufferOffset(3)..MultiBufferOffset(4), true, false);
+        assert_eq!(
+            reversed_at_right_anchored_inlay.range,
+            display_point(10)..display_point(11)
+        );
+        assert_eq!(reversed_at_right_anchored_inlay.head, display_point(10));
+
+        // An empty selection is a typing position, so it keeps the anchor-bias
+        // display position rather than snapping around boundary inlays.
+        let empty = layout(MultiBufferOffset(1)..MultiBufferOffset(1), false, false);
+        assert!(empty.range.is_empty());
+        assert_eq!(empty.head, display_point(6));
+
+        let vim_ending_at_inlay = layout(MultiBufferOffset(0)..MultiBufferOffset(1), false, true);
+        assert_eq!(
+            vim_ending_at_inlay.range,
+            display_point(0)..display_point(1)
+        );
+        assert_eq!(vim_ending_at_inlay.head, display_point(0));
+
+        let vim_spanning_inlay = layout(MultiBufferOffset(0)..MultiBufferOffset(2), false, true);
+        assert_eq!(vim_spanning_inlay.range, display_point(0)..display_point(7));
+        assert_eq!(vim_spanning_inlay.head, display_point(6));
+
+        let vim_reversed_at_right_anchored_inlay =
+            layout(MultiBufferOffset(3)..MultiBufferOffset(4), true, true);
+        assert_eq!(
+            vim_reversed_at_right_anchored_inlay.range,
+            display_point(10)..display_point(11)
+        );
+        assert_eq!(vim_reversed_at_right_anchored_inlay.head, display_point(10));
+    }
+
     #[gpui::test]
     async fn test_soft_wrap_editor_width_auto_height_editor(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
@@ -11273,6 +11421,57 @@ mod tests {
         assert_eq!(relative_rows[&DisplayRow(5)], 2);
     }
 
+    #[gpui::test]
+    async fn test_relative_line_numbers_after_scrolling_wrapped_line(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let window = cx.add_window(|window, cx| {
+            let buffer = MultiBuffer::build_simple("", cx);
+            Editor::new(EditorMode::full(), buffer, None, window, cx)
+        });
+
+        update_test_language_settings(
+            cx,
+            &|settings: &mut settings::AllLanguageSettingsContent| {
+                settings.defaults.soft_wrap = Some(language_settings::SoftWrap::Bounded);
+                settings.defaults.preferred_line_length = Some(10);
+            },
+        );
+
+        window
+            .update(cx, |editor, _window, cx| {
+                let text = format!("{}\nshort line", "a".repeat(100));
+                editor.buffer.update(cx, |buffer, cx| {
+                    buffer.edit([(Point::default()..Point::default(), text)], None, cx);
+                });
+            })
+            .unwrap();
+        cx.run_until_parked();
+
+        window
+            .update(cx, |editor, _window, cx| {
+                let snapshot = editor.snapshot(_window, cx);
+
+                let line_1_display_row = Point::new(1, 0).to_display_point(&snapshot).row();
+                assert!(
+                    line_1_display_row.0 > 1,
+                    "Line 0 should wrap into multiple rows"
+                );
+
+                let start_row = DisplayRow(1);
+                let relative_rows = snapshot.calculate_relative_line_numbers(
+                    &(start_row..line_1_display_row.next_row()),
+                    line_1_display_row,
+                    false,
+                );
+
+                // If the bug exists, line_1_display_row would have a non-zero relative number
+                // and would be included in the map. It should be 0 (and thus omitted).
+                assert!(!relative_rows.contains_key(&line_1_display_row));
+            })
+            .unwrap();
+    }
+
     #[gpui::test]
     async fn test_vim_visual_selections(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
@@ -11327,20 +11526,20 @@ mod tests {
             DisplayPoint::new(DisplayRow(3), 2)
         );
 
-        // leaves cursor on the max point
+        // displays the trailing newline cursor on the preceding row
         assert_eq!(
             local_selections[2].range,
             DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(6), 0)
         );
         assert_eq!(
             local_selections[2].head,
-            DisplayPoint::new(DisplayRow(6), 0)
+            DisplayPoint::new(DisplayRow(5), 6)
         );
 
         // active lines does not include 1 (even though the range of the selection does)
         assert_eq!(
             state.active_rows.keys().cloned().collect::>(),
-            vec![DisplayRow(0), DisplayRow(3), DisplayRow(5), DisplayRow(6)]
+            vec![DisplayRow(0), DisplayRow(3), DisplayRow(5)]
         );
     }
 
diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs
index 7ba0572478096c..ef6435ffd8a40e 100644
--- a/crates/editor/src/element/header.rs
+++ b/crates/editor/src/element/header.rs
@@ -12,7 +12,7 @@ use gpui::{
     linear_color_stop, linear_gradient, point, px, size,
 };
 use language::language_settings::ShowWhitespaceSetting;
-use multi_buffer::{Anchor, ExcerptBoundaryInfo};
+use multi_buffer::{Anchor, ExcerptBoundaryInfo, MultiBuffer};
 use project::Entry;
 use settings::{RelativeLineNumbers, Settings};
 use smallvec::SmallVec;
@@ -20,8 +20,8 @@ use sum_tree::Bias;
 use text::BufferId;
 use theme::ActiveTheme;
 use ui::{
-    ButtonLike, ContextMenu, Indicator, KeyBinding, Tooltip, prelude::*, right_click_menu,
-    text_for_keystroke,
+    ButtonLike, ContextMenu, DiffStat, Indicator, KeyBinding, Tooltip, prelude::*,
+    right_click_menu, text_for_keystroke, utils::WithRemSize,
 };
 use util::ResultExt;
 use workspace::{ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel};
@@ -621,6 +621,13 @@ pub(crate) fn render_buffer_header(
     window: &mut Window,
     cx: &mut App,
 ) -> impl IntoElement {
+    let buffer_id = for_excerpt.buffer_id();
+    let header_hovered_state = window.use_keyed_state(
+        ("buffer-header-hovered", buffer_id.to_proto()),
+        cx,
+        |_, _| false,
+    );
+    let header_hovered = *header_hovered_state.read(cx);
     let editor_read = editor.read(cx);
     let multi_buffer = editor_read.buffer.read(cx);
     let is_read_only = editor_read.read_only(cx);
@@ -634,11 +641,16 @@ pub(crate) fn render_buffer_header(
         None
     };
 
-    let buffer_id = for_excerpt.buffer_id();
     let file_status = multi_buffer
         .all_diff_hunks_expanded()
         .then(|| editor_read.status_for_buffer_id(buffer_id, cx))
         .flatten();
+    let diff_stat = multi_buffer
+        .all_diff_hunks_expanded()
+        .then(|| multibuffer_snapshot.diff_for_buffer_id(buffer_id))
+        .flatten()
+        .map(|diff| diff.changed_row_counts())
+        .filter(|(added, removed)| *added > 0 || *removed > 0);
     let indicator = multi_buffer.buffer(buffer_id).and_then(|buffer| {
         let buffer = buffer.read(cx);
         let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) {
@@ -670,14 +682,26 @@ pub(crate) fn render_buffer_header(
     };
     let focus_handle = editor_read.focus_handle(cx);
     let colors = cx.theme().colors();
-    // On transparent windows `editor_subheader_background` stacks over the
-    // editor background into a darker bar (and the sticky shadow becomes a halo),
-    // so skip both unless the window is opaque.
+    // On transparent windows, only render an opaque `editor_subheader_background` so it masks
+    // the editor content beneath it without creating a darker bar. Sticky shadows still require
+    // an opaque window to avoid rendering as a halo.
     let opaque_window =
         cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque;
+    let show_header_background = opaque_window || colors.editor_subheader_background.is_opaque();
+
+    let show_open_file_button =
+        can_open_excerpts && relative_path.is_some() && (is_selected || header_hovered);
 
     let header = div()
         .id(("buffer-header", buffer_id.to_proto()))
+        .on_hover(move |hovered, _window, cx| {
+            header_hovered_state.update(cx, |state, cx| {
+                if *state != *hovered {
+                    *state = *hovered;
+                    cx.notify();
+                }
+            });
+        })
         .p(BUFFER_HEADER_PADDING)
         .w_full()
         .h(FILE_HEADER_HEIGHT as f32 * window.line_height())
@@ -690,7 +714,6 @@ pub(crate) fn render_buffer_header(
                 .pr_2()
                 .rounded_sm()
                 .gap_1p5()
-                .when(is_sticky && opaque_window, |el| el.shadow_md())
                 .border_1()
                 .map(|border| {
                     let border_color =
@@ -701,10 +724,11 @@ pub(crate) fn render_buffer_header(
                         };
                     border.border_color(border_color)
                 })
-                .when(opaque_window, |el| {
-                    el.bg(colors.editor_subheader_background)
+                .when(is_sticky && opaque_window, |s| s.shadow_md())
+                .when(show_header_background, |s| {
+                    s.bg(colors.editor_subheader_background)
                 })
-                .hover(|style| style.bg(colors.element_hover))
+                .hover(|s| s.bg(colors.element_hover))
                 .map(|header| {
                     let editor = editor.clone();
                     let buffer_id = for_excerpt.buffer_id();
@@ -797,7 +821,7 @@ pub(crate) fn render_buffer_header(
                             |path_header| {
                                 let filename = filename
                                     .map(SharedString::from)
-                                    .unwrap_or_else(|| "untitled".into());
+                                    .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into());
 
                                 let full_path = match parent_path.as_deref() {
                                     Some(parent) if !parent.is_empty() => {
@@ -883,15 +907,26 @@ pub(crate) fn render_buffer_header(
                                     })
                             },
                         ))
-                        .when(can_open_excerpts && relative_path.is_some(), |this| {
-                            this.child(
-                                div()
-                                    .when(!is_selected, |this| {
-                                        this.visible_on_hover("buffer-header-group")
-                                    })
-                                    .child(
+                        .child(
+                            h_flex()
+                                .gap_2()
+                                .when_some(diff_stat, |this, (added, removed)| {
+                                    let ui_font_size =
+                                        theme_settings::ThemeSettings::get_global(cx)
+                                            .ui_font_size(cx);
+                                    this.child(WithRemSize::new(ui_font_size).child(DiffStat::new(
+                                        ("buffer-header-diff-stat", buffer_id.to_proto()),
+                                        added as usize,
+                                        removed as usize,
+                                    )))
+                                })
+                                .when(show_open_file_button, |this| {
+                                    this.child(
                                         Button::new("open-file-button", "Open File")
-                                            .style(ButtonStyle::OutlinedGhost)
+                                            .style(ButtonStyle::OutlinedCustom(
+                                                cx.theme().colors().border.opacity(0.6),
+                                            ))
+                                            .layer(ui::ElevationIndex::ElevatedSurface)
                                             .when(is_selected, |this| {
                                                 this.key_binding(KeyBinding::for_action_in(
                                                     &OpenExcerpts,
@@ -910,9 +945,9 @@ pub(crate) fn render_buffer_header(
                                                     );
                                                 }
                                             })),
-                                    ),
-                            )
-                        })
+                                    )
+                                }),
+                        )
                         .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
                         .on_click(window.listener_for(editor, {
                             let buffer_id = for_excerpt.buffer_id();
@@ -941,7 +976,7 @@ pub(crate) fn render_buffer_header(
     let editor = editor.clone();
     let buffer_snapshot = buffer.clone();
 
-    right_click_menu("buffer-header-context-menu")
+    right_click_menu(("buffer-header-context-menu", buffer_id.to_proto()))
         .trigger(move |_, _, _| header)
         .menu(move |window, cx| {
             let menu_context = focus_handle.clone();
@@ -1052,7 +1087,7 @@ pub(crate) fn render_buffer_header(
         })
 }
 
-fn file_status_label_color(file_status: Option) -> Color {
+pub fn file_status_label_color(file_status: Option) -> Color {
     file_status.map_or(Color::Default, |status| {
         if status.is_conflicted() {
             Color::Conflict
diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs
index 7224fa15d4ce26..ab5b035a05a5f4 100644
--- a/crates/editor/src/git.rs
+++ b/crates/editor/src/git.rs
@@ -2,20 +2,242 @@ pub(super) mod blame;
 
 use super::*;
 use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
-use buffer_diff::DiffHunkStatus;
-
-pub type RenderDiffHunkControlsFn = Arc<
-    dyn Fn(
-        u32,
-        &DiffHunkStatus,
-        Range,
-        bool,
-        Pixels,
-        &Entity,
-        &mut Window,
-        &mut App,
-    ) -> AnyElement,
->;
+use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind};
+
+#[derive(Clone)]
+pub struct ResolvedDiffHunk {
+    pub buffer_range: Range,
+    pub diff_base_byte_range: Range,
+    pub status: DiffHunkStatus,
+}
+
+#[derive(Clone)]
+pub struct ResolvedDiffHunks {
+    pub diff: Entity,
+    pub buffer_id: BufferId,
+    pub buffer: Option>,
+    pub hunks: Vec,
+}
+
+pub trait DiffHunkDelegate {
+    fn toggle(
+        &self,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    );
+
+    fn stage_or_unstage(
+        &self,
+        stage: bool,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    );
+
+    fn restore(
+        &self,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if hunks.is_empty() || editor.read_only(cx) {
+            return;
+        }
+        self.stage_or_unstage(false, hunks.clone(), editor, window, cx);
+        editor.transact(window, cx, |editor, window, cx| {
+            editor.restore_diff_hunks(hunks, cx);
+            let selections = editor
+                .selections
+                .all::(&editor.display_snapshot(cx));
+            editor.change_selections(
+                SelectionEffects::no_scroll(),
+                window,
+                cx,
+                |selections_state| {
+                    selections_state.select(selections);
+                },
+            );
+        });
+    }
+
+    fn render_hunk_controls(
+        &self,
+        row: u32,
+        status: &DiffHunkStatus,
+        hunk_range: Range,
+        is_created_file: bool,
+        line_height: Pixels,
+        editor: &Entity,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement;
+
+    fn render_hunk_as_staged(&self, status: &DiffHunkStatus, _cx: &App) -> bool {
+        !status.has_secondary_hunk()
+    }
+}
+
+pub struct UncommittedDiffHunkDelegate;
+
+impl DiffHunkDelegate for UncommittedDiffHunkDelegate {
+    fn toggle(
+        &self,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let stage = hunks
+            .iter()
+            .flat_map(|hunks| hunks.hunks.iter())
+            .any(|hunk| hunk.status.has_secondary_hunk());
+        self.stage_or_unstage(stage, hunks, editor, window, cx);
+    }
+
+    fn stage_or_unstage(
+        &self,
+        stage: bool,
+        hunks: Vec,
+        editor: &mut Editor,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(project) = editor.project() else {
+            return;
+        };
+        for hunks in hunks {
+            let Some(buffer) = hunks.buffer else {
+                continue;
+            };
+            let ranges = hunks
+                .hunks
+                .into_iter()
+                .map(|hunk| hunk.buffer_range)
+                .collect::>();
+            if ranges.is_empty() {
+                continue;
+            }
+            let secondary_diff = hunks.diff.read(cx).secondary_diff();
+            project
+                .update(cx, |project, cx| {
+                    if stage {
+                        let Some(secondary_diff) = secondary_diff else {
+                            return Err(anyhow::anyhow!("diff has no unstaged secondary"));
+                        };
+                        project.stage_hunks(buffer, secondary_diff, ranges, cx)
+                    } else {
+                        project.unstage_uncommitted_hunks(buffer, hunks.diff, ranges, cx)
+                    }
+                })
+                .log_err();
+        }
+    }
+
+    fn render_hunk_controls(
+        &self,
+        row: u32,
+        status: &DiffHunkStatus,
+        hunk_range: Range,
+        is_created_file: bool,
+        line_height: Pixels,
+        editor: &Entity,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement {
+        render_diff_hunk_controls(
+            row,
+            status,
+            hunk_range,
+            is_created_file,
+            line_height,
+            editor,
+            window,
+            cx,
+        )
+    }
+}
+
+pub struct RestoreOnlyDiffHunkDelegate;
+
+impl DiffHunkDelegate for RestoreOnlyDiffHunkDelegate {
+    fn toggle(
+        &self,
+        _hunks: Vec,
+        _editor: &mut Editor,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+
+    fn stage_or_unstage(
+        &self,
+        _stage: bool,
+        _hunks: Vec,
+        _editor: &mut Editor,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+
+    fn render_hunk_controls(
+        &self,
+        _row: u32,
+        _status: &DiffHunkStatus,
+        _hunk_range: Range,
+        _is_created_file: bool,
+        _line_height: Pixels,
+        _editor: &Entity,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> AnyElement {
+        gpui::Empty.into_any_element()
+    }
+}
+
+pub struct RestoreOnlyUnstagedDiffHunkDelegate;
+
+impl DiffHunkDelegate for RestoreOnlyUnstagedDiffHunkDelegate {
+    fn toggle(
+        &self,
+        _hunks: Vec,
+        _editor: &mut Editor,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+
+    fn stage_or_unstage(
+        &self,
+        _stage: bool,
+        _hunks: Vec,
+        _editor: &mut Editor,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+
+    fn render_hunk_controls(
+        &self,
+        _row: u32,
+        _status: &DiffHunkStatus,
+        _hunk_range: Range,
+        _is_created_file: bool,
+        _line_height: Pixels,
+        _editor: &Entity,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> AnyElement {
+        gpui::Empty.into_any_element()
+    }
+
+    fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool {
+        false
+    }
+}
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub(super) enum DisplayDiffHunk {
@@ -166,24 +388,115 @@ impl Editor {
         })
     }
 
-    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();
+    fn resolve_diff_hunks(
+        &self,
+        hunks: Vec,
+        cx: &App,
+    ) -> Vec {
+        let multibuffer = self.buffer().read(cx);
+        let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id);
+        let mut resolved = Vec::new();
+
+        for (source_buffer_id, hunks) in &chunk_by {
+            let Some(diff) = multibuffer.diff_for(source_buffer_id) else {
+                continue;
+            };
+            let diff_snapshot = diff.read(cx).snapshot(cx);
+            let main_buffer_id = diff_snapshot.buffer_id();
+            let buffer = multibuffer.buffer(main_buffer_id).or_else(|| {
+                self.project
+                    .as_ref()
+                    .and_then(|project| project.read(cx).buffer_for_id(main_buffer_id, cx))
+            });
+            let mut resolved_hunks = Vec::new();
+
+            for hunk in hunks {
+                if hunk.buffer_id == main_buffer_id {
+                    resolved_hunks.push(ResolvedDiffHunk {
+                        buffer_range: hunk.buffer_range,
+                        diff_base_byte_range: hunk.diff_base_byte_range.start.0
+                            ..hunk.diff_base_byte_range.end.0,
+                        status: hunk.status,
+                    });
+                } else {
+                    let diff_base_byte_range =
+                        hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0;
+                    let Some(hunk) = diff_snapshot
+                        .hunks_intersecting_base_text_range(
+                            diff_base_byte_range.clone(),
+                            diff_snapshot.buffer_snapshot(),
+                        )
+                        .find(|hunk| hunk.diff_base_byte_range == diff_base_byte_range)
+                    else {
+                        continue;
+                    };
+                    let kind = if hunk.buffer_range.start == hunk.buffer_range.end {
+                        DiffHunkStatusKind::Deleted
+                    } else if hunk.diff_base_byte_range.is_empty() {
+                        DiffHunkStatusKind::Added
+                    } else {
+                        DiffHunkStatusKind::Modified
+                    };
+                    resolved_hunks.push(ResolvedDiffHunk {
+                        buffer_range: hunk.buffer_range,
+                        diff_base_byte_range: hunk.diff_base_byte_range,
+                        status: DiffHunkStatus {
+                            kind,
+                            secondary: hunk.secondary_status,
+                        },
+                    });
+                }
+            }
+
+            if !resolved_hunks.is_empty() {
+                resolved.push(ResolvedDiffHunks {
+                    diff,
+                    buffer_id: main_buffer_id,
+                    buffer,
+                    hunks: resolved_hunks,
+                });
+            }
+        }
+
+        resolved
     }
 
-    /// Make all diff hunks render with the "unstaged" appearance, regardless
-    /// of whether they have a secondary hunk. Intended for views whose diffs
-    /// aren't related to the git index (e.g. agent diffs).
-    pub fn set_render_diff_hunks_as_unstaged(
+    pub fn diff_hunk_delegate(&self) -> Arc {
+        self.diff_hunk_delegate
+            .clone()
+            .unwrap_or_else(|| Arc::new(UncommittedDiffHunkDelegate))
+    }
+
+    pub fn set_diff_hunk_delegate(
         &mut self,
-        render_as_unstaged: bool,
+        delegate: Option>,
         cx: &mut Context,
     ) {
-        self.render_diff_hunks_as_unstaged = render_as_unstaged;
+        let had_delegate = self.diff_hunk_delegate.is_some();
+        let has_delegate = delegate.is_some();
+        self.diff_hunk_delegate = delegate;
+
+        if !had_delegate && has_delegate {
+            self.load_diff_task.take();
+        } else if had_delegate && !has_delegate {
+            self.buffer.update(cx, |buffer, cx| {
+                buffer.set_all_diff_hunks_collapsed(cx);
+            });
+
+            if let Some(project) = self.project.clone() {
+                self.load_diff_task = Some(
+                    update_uncommitted_diff_for_buffer(
+                        cx.entity(),
+                        &project,
+                        self.buffer.read(cx).all_buffers(),
+                        self.buffer.clone(),
+                        cx,
+                    )
+                    .shared(),
+                );
+            }
+        }
+
         cx.notify();
     }
 
@@ -265,33 +578,6 @@ impl Editor {
         cx.notify();
     }
 
-    pub fn start_temporary_diff_override(&mut self) {
-        self.load_diff_task.take();
-        self.temporary_diff_override = true;
-    }
-
-    pub fn end_temporary_diff_override(&mut self, cx: &mut Context) {
-        self.temporary_diff_override = false;
-        self.render_diff_hunks_as_unstaged = false;
-        self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx);
-        self.buffer.update(cx, |buffer, cx| {
-            buffer.set_all_diff_hunks_collapsed(cx);
-        });
-
-        if let Some(project) = self.project.clone() {
-            self.load_diff_task = Some(
-                update_uncommitted_diff_for_buffer(
-                    cx.entity(),
-                    &project,
-                    self.buffer.read(cx).all_buffers(),
-                    self.buffer.clone(),
-                    cx,
-                )
-                .shared(),
-            );
-        }
-    }
-
     /// Hides the inline blame popover element, in case it's already visible, or
     /// interrupts the task meant to show it, in case the task is running.
     ///
@@ -638,6 +924,29 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) {
+        let just_started = self.blame.is_none();
+        if just_started {
+            self.start_git_blame(true, window, cx);
+        }
+        let Some(blame) = self.blame.as_ref() else {
+            return;
+        };
+
+        if just_started && !blame.read(cx).has_generated_entries() {
+            let subscription = cx.observe_in(blame, window, |editor, blame, window, cx| {
+                if blame.read(cx).has_generated_entries() {
+                    editor.pending_blame_hover_observation.take();
+                    editor.show_blame_hover_popover(window, cx);
+                }
+            });
+            self.pending_blame_hover_observation = Some(subscription);
+            return;
+        }
+
+        self.show_blame_hover_popover(window, cx);
+    }
+
+    fn show_blame_hover_popover(&mut self, window: &mut Window, cx: &mut Context) {
         let snapshot = self.snapshot(window, cx);
         let cursor = self
             .selections
@@ -647,9 +956,6 @@ impl Editor {
             return;
         };
 
-        if self.blame.is_none() {
-            self.start_git_blame(true, window, cx);
-        }
         let Some(blame) = self.blame.as_ref() else {
             return;
         };
@@ -744,31 +1050,44 @@ impl Editor {
         );
     }
 
-    pub(super) fn restore_diff_hunks(&self, hunks: Vec, cx: &mut App) {
-        let mut revert_changes = HashMap::default();
-        let chunk_by = hunks.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);
-        }
-        if !revert_changes.is_empty() {
-            self.buffer().update(cx, |multi_buffer, cx| {
-                for (buffer_id, changes) in revert_changes {
-                    if let Some(buffer) = multi_buffer.buffer(buffer_id) {
-                        buffer.update(cx, |buffer, cx| {
-                            buffer.edit(
-                                changes
-                                    .into_iter()
-                                    .map(|(range, text)| (range, text.to_string())),
-                                None,
-                                cx,
-                            );
-                        });
+    pub fn restore_diff_hunks(&mut self, hunks: Vec, cx: &mut Context) {
+        let mut revert_changes = Vec::new();
+        for hunks in hunks {
+            let Some(buffer) = hunks.buffer else {
+                continue;
+            };
+            let diff_snapshot = hunks.diff.read(cx).snapshot(cx);
+            let changes = hunks
+                .hunks
+                .into_iter()
+                .filter_map(|hunk| {
+                    if hunk.diff_base_byte_range == (0..0)
+                        && hunk.buffer_range.start.is_min()
+                        && hunk.buffer_range.end.is_max()
+                    {
+                        return None;
                     }
-                }
+                    let original_text = diff_snapshot
+                        .base_text()
+                        .as_rope()
+                        .slice(hunk.diff_base_byte_range.start..hunk.diff_base_byte_range.end);
+                    Some((hunk.buffer_range, original_text))
+                })
+                .collect::>();
+            if !changes.is_empty() {
+                revert_changes.push((buffer, changes));
+            }
+        }
+
+        for (buffer, changes) in revert_changes {
+            buffer.update(cx, |buffer, cx| {
+                buffer.edit(
+                    changes
+                        .into_iter()
+                        .map(|(range, text)| (range, text.to_string())),
+                    None,
+                    cx,
+                );
             });
         }
     }
@@ -1401,18 +1720,25 @@ impl Editor {
     pub(super) fn toggle_staged_selected_diff_hunks(
         &mut self,
         _: &::git::ToggleStaged,
-        _: &mut Window,
+        window: &mut Window,
         cx: &mut Context,
     ) {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
         let ranges: Vec<_> = self
             .selections
             .disjoint_anchors()
             .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);
+        let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
+        cx.spawn_in(window, async move |this, cx| {
+            task.await?;
+            this.update_in(cx, |this, window, cx| {
+                let snapshot = this.buffer.read(cx).snapshot(cx);
+                let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect();
+                this.apply_toggle(hunks, window, cx);
+            })
+        })
+        .detach_and_log_err(cx);
     }
 
     pub(super) fn stage_and_next(
@@ -1433,42 +1759,47 @@ impl Editor {
         self.do_stage_or_unstage_and_next(false, window, cx);
     }
 
-    pub(super) fn do_stage_or_unstage(
-        &self,
+    pub fn apply_toggle(
+        &mut self,
+        hunks: Vec,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let hunks = self.resolve_diff_hunks(hunks, cx);
+        if hunks.is_empty() {
+            return;
+        }
+        let delegate = self.diff_hunk_delegate();
+        delegate.toggle(hunks, self, window, cx);
+    }
+
+    pub fn apply_stage_or_unstage(
+        &mut self,
         stage: bool,
-        buffer_id: BufferId,
-        hunks: impl Iterator,
-        cx: &mut App,
-    ) -> Option<()> {
-        let project = self.project()?;
-        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,
-                        // We don't need to pass in word diffs here because they're only used for rendering and
-                        // this function changes internal state
-                        base_word_diffs: Vec::default(),
-                        buffer_word_diffs: Vec::default(),
-                        diff_base_byte_range: hunk.diff_base_byte_range.start.0
-                            ..hunk.diff_base_byte_range.end.0,
-                        secondary_status: hunk.status.secondary,
-                        range: Point::zero()..Point::zero(), // unused
-                    })
-                    .collect::>(),
-                &buffer_snapshot,
-                file_exists,
-                cx,
-            )
-        });
-        None
+        hunks: Vec,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let hunks = self.resolve_diff_hunks(hunks, cx);
+        if hunks.is_empty() {
+            return;
+        }
+        let delegate = self.diff_hunk_delegate();
+        delegate.stage_or_unstage(stage, hunks, self, window, cx);
+    }
+
+    pub fn apply_restore(
+        &mut self,
+        hunks: Vec,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let hunks = self.resolve_diff_hunks(hunks, cx);
+        if hunks.is_empty() {
+            return;
+        }
+        let delegate = self.diff_hunk_delegate();
+        delegate.restore(hunks, self, window, cx);
     }
 
     pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool {
@@ -1756,36 +2087,36 @@ impl Editor {
         }
     }
 
-    fn stage_or_unstage_diff_hunks(
+    pub fn stage_or_unstage_diff_hunks(
         &mut self,
         stage: bool,
         ranges: Vec>,
+        window: &mut Window,
         cx: &mut Context,
     ) {
-        if self.delegate_stage_and_restore {
-            let snapshot = self.buffer.read(cx).snapshot(cx);
-            let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect();
-            if !hunks.is_empty() {
-                cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks });
-            }
-            return;
-        }
         let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
-        cx.spawn(async move |this, cx| {
+        cx.spawn_in(window, async move |this, cx| {
             task.await?;
-            this.update(cx, |this, cx| {
+            this.update_in(cx, |this, window, 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);
-                }
+                let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect();
+                this.apply_stage_or_unstage(stage, hunks, window, cx);
             })
         })
         .detach_and_log_err(cx);
     }
 
+    pub fn restore_diff_hunks_in_ranges(
+        &mut self,
+        ranges: Vec>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let hunks = self.diff_hunks_in_ranges(&ranges, &snapshot).collect();
+        self.apply_restore(hunks, window, cx);
+    }
+
     fn toggle_diff_hunks_in_ranges(
         &mut self,
         ranges: Vec>,
@@ -1827,66 +2158,8 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        if self.delegate_stage_and_restore {
-            let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges);
-            if !hunks.is_empty() {
-                cx.emit(EditorEvent::RestoreRequested { hunks });
-            }
-            return;
-        }
         let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges);
-        self.transact(window, cx, |editor, window, cx| {
-            editor.restore_diff_hunks(hunks, cx);
-            let selections = editor
-                .selections
-                .all::(&editor.display_snapshot(cx));
-            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
-                s.select(selections);
-            });
-        });
-    }
-
-    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())
-    }
-
-    fn prepare_restore_change(
-        &self,
-        revert_changes: &mut HashMap, Rope)>>,
-        hunk: &MultiBufferDiffHunk,
-        cx: &mut App,
-    ) -> Option<()> {
-        if hunk.is_created_file() {
-            return None;
-        }
-        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| {
-            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
-        }
+        self.apply_restore(hunks, window, cx);
     }
 
     fn save_buffers_for_ranges_if_needed(
@@ -1929,11 +2202,11 @@ impl Editor {
         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);
+            self.stage_or_unstage_diff_hunks(stage, ranges, window, cx);
             return;
         }
 
-        self.stage_or_unstage_diff_hunks(stage, ranges, cx);
+        self.stage_or_unstage_diff_hunks(stage, ranges, window, cx);
 
         let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded();
         let wrap_around = !all_diff_hunks_expanded;
@@ -2658,7 +2931,7 @@ pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App)
     cx.set_global(GlobalBlameRenderer(Arc::new(renderer)));
 }
 
-pub(super) fn render_diff_hunk_controls(
+pub fn render_diff_hunk_controls(
     row: u32,
     status: &DiffHunkStatus,
     hunk_range: Range,
@@ -2703,11 +2976,12 @@ pub(super) fn render_diff_hunk_controls(
                     })
                     .on_click({
                         let editor = editor.clone();
-                        move |_event, _window, cx| {
+                        move |_event, window, cx| {
                             editor.update(cx, |editor, cx| {
                                 editor.stage_or_unstage_diff_hunks(
                                     true,
                                     vec![hunk_range.start..hunk_range.start],
+                                    window,
                                     cx,
                                 );
                             });
@@ -2729,11 +3003,12 @@ pub(super) fn render_diff_hunk_controls(
                     })
                     .on_click({
                         let editor = editor.clone();
-                        move |_event, _window, cx| {
+                        move |_event, window, cx| {
                             editor.update(cx, |editor, cx| {
                                 editor.stage_or_unstage_diff_hunks(
                                     false,
                                     vec![hunk_range.start..hunk_range.start],
+                                    window,
                                     cx,
                                 );
                             });
@@ -2860,7 +3135,7 @@ pub(super) fn update_uncommitted_diff_for_buffer(
     });
     cx.spawn(async move |cx| {
         let diffs = future::join_all(tasks).await;
-        if editor.read_with(cx, |editor, _cx| editor.temporary_diff_override) {
+        if editor.read_with(cx, |editor, _cx| editor.diff_hunk_delegate.is_some()) {
             return;
         }
 
diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs
index 0e6ad1cb0ea6f1..347a1e8dafcca7 100644
--- a/crates/editor/src/git/blame.rs
+++ b/crates/editor/src/git/blame.rs
@@ -8,8 +8,8 @@ use git::{
     commit::ParsedCommitMessage,
 };
 use gpui::{
-    AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
-    TextStyle, WeakEntity, Window,
+    AnyElement, App, AppContext as _, Context, Entity, Hsla, Pixels, ScrollHandle, SharedString,
+    Subscription, Task, TextStyle, WeakEntity, Window,
 };
 use itertools::Itertools;
 use language::{Bias, BufferSnapshot, Edit};
@@ -69,6 +69,7 @@ struct GitBlameBuffer {
     buffer_snapshot: BufferSnapshot,
     buffer_edits: text::Subscription,
     commit_details: HashMap,
+    commit_tag_names: HashMap>,
 }
 
 pub struct GitBlame {
@@ -86,11 +87,16 @@ pub struct GitBlame {
 pub trait BlameRenderer {
     fn max_author_length(&self) -> usize;
 
+    fn blame_entry_non_text_width(&self, _: &Window, _: &App) -> Pixels {
+        Pixels::ZERO
+    }
+
     fn render_blame_entry(
         &self,
         _: &TextStyle,
         _: BlameEntry,
         _: Option,
+        _: Vec,
         _: Entity,
         _: WeakEntity,
         _: Entity,
@@ -112,6 +118,7 @@ pub trait BlameRenderer {
         _: BlameEntry,
         _: ScrollHandle,
         _: Option,
+        _: Vec,
         _: Entity,
         _: Entity,
         _: WeakEntity,
@@ -139,6 +146,7 @@ impl BlameRenderer for () {
         _: &TextStyle,
         _: BlameEntry,
         _: Option,
+        _: Vec,
         _: Entity,
         _: WeakEntity,
         _: Entity,
@@ -164,6 +172,7 @@ impl BlameRenderer for () {
         _: BlameEntry,
         _: ScrollHandle,
         _: Option,
+        _: Vec,
         _: Entity,
         _: Entity,
         _: WeakEntity,
@@ -289,6 +298,14 @@ impl GitBlame {
             .cloned()
     }
 
+    pub fn tag_names_for_entry(&self, buffer: BufferId, entry: &BlameEntry) -> Vec {
+        self.buffers
+            .get(&buffer)
+            .and_then(|buffer| buffer.commit_tag_names.get(&entry.sha))
+            .cloned()
+            .unwrap_or_default()
+    }
+
     pub fn blame_for_rows<'a>(
         &'a mut self,
         rows: &'a [RowInfo],
@@ -557,7 +574,11 @@ impl GitBlame {
                             let mut errors = vec![];
                             for (id, snapshot, buffer_edits, blame, remote_url) in blame {
                                 match blame {
-                                    Ok(Some(Blame { entries, messages })) => {
+                                    Ok(Some(Blame {
+                                        entries,
+                                        messages,
+                                        tag_names,
+                                    })) => {
                                         let entries = build_blame_entry_sum_tree(
                                             entries,
                                             snapshot.max_point().row,
@@ -575,12 +596,25 @@ impl GitBlame {
                                                 (oid, parsed_commit_message)
                                             })
                                             .collect();
+                                        let commit_tag_names = tag_names
+                                            .into_iter()
+                                            .map(|(oid, tag_names)| {
+                                                (
+                                                    oid,
+                                                    tag_names
+                                                        .into_iter()
+                                                        .map(SharedString::from)
+                                                        .collect(),
+                                                )
+                                            })
+                                            .collect();
                                         res.push((
                                             id,
                                             snapshot,
                                             buffer_edits,
                                             Some(entries),
                                             commit_details,
+                                            commit_tag_names,
                                         ));
                                     }
                                     Ok(None) => res.push((
@@ -589,6 +623,7 @@ impl GitBlame {
                                         buffer_edits,
                                         None,
                                         Default::default(),
+                                        Default::default(),
                                     )),
                                     Err(e) => errors.push(e),
                                 }
@@ -603,7 +638,9 @@ impl GitBlame {
 
             this.update(cx, |this, cx| {
                 this.buffers.clear();
-                for (id, snapshot, buffer_edits, entries, commit_details) in all_results {
+                for (id, snapshot, buffer_edits, entries, commit_details, commit_tag_names) in
+                    all_results
+                {
                     let Some(entries) = entries else {
                         continue;
                     };
@@ -614,6 +651,7 @@ impl GitBlame {
                             buffer_snapshot: snapshot,
                             entries,
                             commit_details,
+                            commit_tag_names,
                         },
                     );
                 }
@@ -1295,4 +1333,101 @@ mod tests {
             filename: String::new(),
         }
     }
+
+    #[gpui::test]
+    async fn test_blame_hover_shows_popover_on_first_trigger(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        cx.update(|cx| {
+            use gpui::UpdateGlobal;
+            settings::SettingsStore::update_global(
+                cx,
+                |store: &mut settings::SettingsStore, cx| {
+                    store
+                        .set_user_settings(r#"{"git": {"inline_blame": {"enabled": false}}}"#, cx)
+                        .expect("failed to set user settings");
+                },
+            );
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/my-repo",
+            json!({
+                ".git": {},
+                "file.txt": "line 1\nline 2\nline 3\n"
+            }),
+        )
+        .await;
+
+        fs.set_blame_for_repo(
+            Path::new("/my-repo/.git"),
+            vec![(
+                repo_path("file.txt"),
+                Blame {
+                    entries: vec![
+                        blame_entry("1b1b1b", 0..1),
+                        blame_entry("2c2c2c", 1..2),
+                        blame_entry("3d3d3d", 2..3),
+                    ],
+                    ..Default::default()
+                },
+            )],
+        );
+
+        let project = project::Project::test(fs, ["/my-repo".as_ref()], cx).await;
+        let buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer("/my-repo/file.txt", cx)
+            })
+            .await
+            .unwrap();
+        let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+
+        let (editor, cx) = cx.add_window_view(|window, cx| {
+            crate::test::build_editor_with_project(project, multi_buffer, window, cx)
+        });
+
+        // Verify blame is not loaded yet
+        editor.update(cx, |editor, _cx| {
+            assert!(
+                editor.blame().is_none(),
+                "blame should not be loaded initially"
+            );
+        });
+
+        // Focus the editor so that blame generation proceeds
+        editor.update_in(cx, |editor, window, cx| {
+            editor.focus_handle.focus(window, cx);
+        });
+
+        // Trigger BlameHover — this should start blame loading and defer showing the popover
+        editor.update_in(cx, |editor, window, cx| {
+            assert!(editor.blame().is_none());
+            editor.blame_hover(&crate::BlameHover, window, cx);
+            assert!(
+                editor.blame().is_some(),
+                "blame entity should be created after blame_hover"
+            );
+            assert!(
+                editor.pending_blame_hover_observation.is_some(),
+                "should have registered an observation to wait for blame data"
+            );
+        });
+
+        // Let the async blame generation complete
+        cx.run_until_parked();
+
+        // The observation should have fired and cleaned itself up
+        editor.update(cx, |editor, cx| {
+            assert!(
+                editor.pending_blame_hover_observation.is_none(),
+                "observation should be consumed after blame data is generated"
+            );
+            assert!(
+                editor.blame().unwrap().read(cx).has_generated_entries(),
+                "blame should have generated entries"
+            );
+        });
+    }
 }
diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs
index 9e943d37daf43f..707ad0d75d5371 100644
--- a/crates/editor/src/hover_links.rs
+++ b/crates/editor/src/hover_links.rs
@@ -335,7 +335,7 @@ impl Editor {
                 }
                 (true, false) => self.go_to_type_definition(&GoToTypeDefinition, window, cx),
                 (false, true) => self.go_to_definition_split(&GoToDefinitionSplit, window, cx),
-                (false, false) => self.go_to_definition(&GoToDefinition, window, cx),
+                (false, false) => self.go_to_definition(&GoToDefinition::default(), window, cx),
             }
         } else {
             Task::ready(Ok(Navigated::No))
@@ -2342,6 +2342,8 @@ Sentence ending file2.rs.
                     .to_vec(),
             )
             .await;
+        // Let the worktree pick up the new file before hovering a link to it.
+        cx.run_until_parked();
 
         // file2.rs:5:3 should be highlighted and clickable
         cx.set_state(indoc! {"
@@ -2418,6 +2420,8 @@ Sentence ending file2.rs.
                     .to_vec(),
             )
             .await;
+        // Let the worktree pick up the new file before hovering a link to it.
+        cx.run_until_parked();
 
         // file2.rs:3 should be highlighted and clickable
         cx.set_state(indoc! {"
@@ -2475,6 +2479,8 @@ Sentence ending file2.rs.
                 "line 1\nline 2\nline 3\n".as_bytes().to_vec(),
             )
             .await;
+        // Let the worktree pick up the new file before hovering a link to it.
+        cx.run_until_parked();
 
         // file2.rs:2:in should resolve to file2.rs line 2 (like Ruby backtraces)
         cx.set_state(indoc! {"
@@ -2533,6 +2539,8 @@ Sentence ending file2.rs.
                     .to_vec(),
             )
             .await;
+        // Let the worktree pick up the new file before hovering a link to it.
+        cx.run_until_parked();
 
         // Markdown link [text](file2.rs:3:2) should highlight only the inner link,
         // not the surrounding markdown syntax.
diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs
index 96aeae19933de9..ff4b8c4b8d055e 100644
--- a/crates/editor/src/hover_popover.rs
+++ b/crates/editor/src/hover_popover.rs
@@ -798,19 +798,33 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
     }
 }
 
+fn parse_file_link(link: &str) -> Option<(PathBuf, Option)> {
+    let uri = Url::parse(link).ok().filter(|uri| uri.scheme() == "file")?;
+    let fragment = uri.fragment().map(ToOwned::to_owned);
+    let path = uri.to_file_path().unwrap_or_else(|_| {
+        let encoded = uri.path();
+
+        urlencoding::decode(encoded)
+            .map(Cow::into_owned)
+            .map(PathBuf::from)
+            .unwrap_or_else(|_| PathBuf::from(encoded))
+    });
+
+    Some((path, fragment))
+}
+
 pub fn open_markdown_url(
     workspace: Option>,
     link: SharedString,
     window: &mut Window,
     cx: &mut App,
 ) {
-    if let Ok(uri) = Url::parse(&link)
-        && uri.scheme() == "file"
+    if let Some((path, fragment)) = parse_file_link(&link)
         && let Some(workspace) = workspace
     {
         workspace.update(cx, |workspace, cx| {
             let task = workspace.open_abs_path(
-                PathBuf::from(uri.path()),
+                path,
                 OpenOptions {
                     visible: Some(OpenVisible::None),
                     ..Default::default()
@@ -823,7 +837,7 @@ pub fn open_markdown_url(
                 let item = task.await?;
                 // Ruby LSP uses URLs with #L1,1-4,4
                 // we'll just take the first number and assume it's a line number
-                let Some(fragment) = uri.fragment() else {
+                let Some(fragment) = fragment else {
                     return anyhow::Ok(());
                 };
                 let mut accum = 0u32;
@@ -2747,4 +2761,21 @@ mod tests {
             );
         });
     }
+
+    #[test]
+    fn test_parse_file_links() {
+        assert_eq!(
+            parse_file_link("file:///path/to/file"),
+            Some((PathBuf::from("/path/to/file"), None))
+        );
+        assert_eq!(
+            parse_file_link("file:///path/to/file%20with%20spaces"),
+            Some((PathBuf::from("/path/to/file with spaces"), None))
+        );
+        assert_eq!(
+            parse_file_link("file:///path/to/file#123"),
+            Some((PathBuf::from("/path/to/file"), Some("123".to_string())))
+        );
+        assert_eq!(parse_file_link("http://example.com/"), None,);
+    }
 }
diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs
index 0efc4355a628e6..fd45160e9c6e41 100644
--- a/crates/editor/src/inlays/inlay_hints.rs
+++ b/crates/editor/src/inlays/inlay_hints.rs
@@ -2087,6 +2087,7 @@ pub mod tests {
                 ..InlayHintSettingsContent::default()
             })
         });
+        crate::editor_tests::pin_upstream_buffer_font_metrics(cx);
 
         let fs = FakeFs::new(cx.background_executor.clone());
         fs.insert_tree(
diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs
index 876bb8fa286535..8192fc4a18f56b 100644
--- a/crates/editor/src/input.rs
+++ b/crates/editor/src/input.rs
@@ -1666,10 +1666,8 @@ impl Editor {
             this.change_selections(Default::default(), window, cx, |s| s.select(selections));
 
             let selections = this.selections.all::(&this.display_snapshot(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_on_single_row = selections.array_windows::<2>().all(|[a, b]| {
+                a.start.row == b.start.row && a.end.row == b.end.row && a.start.row == a.end.row
             });
             let selections_selecting = selections
                 .iter()
diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs
index 31018f944d1049..3028cb2e574475 100644
--- a/crates/editor/src/items.rs
+++ b/crates/editor/src/items.rs
@@ -18,13 +18,15 @@ use gpui::{
     IntoElement, ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
 };
 use language::{
-    Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, Point,
-    SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
+    Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT,
+    Point, SelectionGoal,
+    language_settings::{FormatOnSave, LanguageSettings},
+    proto::serialize_anchor as serialize_text_anchor,
 };
 use lsp::DiagnosticSeverity;
 use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey};
 use project::{
-    File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger,
+    File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger,
     project_settings::ProjectSettings, search::SearchQuery,
 };
 use rope::TextSummary;
@@ -39,9 +41,9 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection};
+use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _};
 use ui::{IconDecorationKind, prelude::*};
-use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath};
+use util::{ResultExt, TryFutureExt, debug_panic, paths::PathExt, rel_path::RelPath};
 use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams, tab_label_color};
 use workspace::{
     CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
@@ -717,7 +719,22 @@ impl Item for Editor {
     }
 
     fn suggested_filename(&self, cx: &App) -> SharedString {
-        self.buffer.read(cx).title(cx).to_string().into()
+        let multi_buffer = self.buffer.read(cx);
+        let title = multi_buffer.title(cx);
+        if let Some(buffer) = multi_buffer.as_singleton() {
+            let buffer = buffer.read(cx);
+            if buffer.file().is_none()
+                && let Some(language) = buffer.language()
+                && *language != *PLAIN_TEXT
+                && let Some(suffix) = language.path_suffixes().first()
+                && !suffix.is_empty()
+                && !title.ends_with(&format!(".{suffix}"))
+            {
+                return format!("{title}.{suffix}").into();
+            }
+        }
+
+        title.to_string().into()
     }
 
     fn tab_icon(&self, _: &Window, cx: &App) -> Option {
@@ -959,16 +976,21 @@ impl Item for Editor {
 
         cx.spawn_in(window, async move |this, cx| {
             if options.format {
-                this.update_in(cx, |editor, window, cx| {
-                    editor.perform_format(
-                        project.clone(),
+                let format_task = this.update_in(cx, |editor, window, cx| {
+                    let format_target = compute_format_target(
+                        &buffers_to_save,
                         format_trigger,
-                        FormatTarget::Buffers(buffers_to_save.clone()),
-                        window,
+                        editor.buffer(),
+                        project.read(cx).git_store(),
                         cx,
-                    )
-                })?
-                .await?;
+                    );
+                    format_target.map(|target| {
+                        editor.perform_format(project.clone(), format_trigger, target, window, cx)
+                    })
+                })?;
+                if let Some(format_task) = format_task {
+                    format_task.await?;
+                }
             }
 
             if !buffers_to_save.is_empty() {
@@ -2078,6 +2100,75 @@ pub fn active_match_index(
     }
 }
 
+/// Opens a path-like target (e.g. `items.rs:100:5`) in the workspace, moving the cursor
+/// to the one-based row/column if present. Returns whether the target was opened.
+pub async fn open_resolved_target(
+    workspace: &WeakEntity,
+    open_target: &workspace::path_link::OpenTarget,
+    cx: &mut AsyncWindowContext,
+) -> Result {
+    let path_to_open = open_target.path();
+    let mut opened_items = workspace
+        .update_in(cx, |workspace, window, cx| {
+            workspace.open_paths(
+                vec![path_to_open.path.clone()],
+                workspace::OpenOptions {
+                    visible: Some(workspace::OpenVisible::OnlyDirectories),
+                    ..Default::default()
+                },
+                None,
+                window,
+                cx,
+            )
+        })
+        .context("workspace update")?
+        .await;
+    if opened_items.len() != 1 {
+        debug_panic!(
+            "Received {} items for one path {path_to_open:?}",
+            opened_items.len(),
+        );
+    }
+    let Some(opened_item) = opened_items.pop() else {
+        return Ok(false);
+    };
+
+    if open_target.is_file() {
+        let Some(opened_item) = opened_item else {
+            return Ok(false);
+        };
+        let opened_item =
+            opened_item.with_context(|| format!("opening {:?}", path_to_open.path))?;
+        if let Some(row) = path_to_open.row
+            && let Some(editor) = opened_item.downcast::()
+        {
+            let column = path_to_open.column.unwrap_or(0);
+            editor
+                .downgrade()
+                .update_in(cx, |editor, window, cx| {
+                    if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
+                        let point = buffer.read(cx).snapshot().point_from_external_input(
+                            row.saturating_sub(1),
+                            column.saturating_sub(1),
+                        );
+                        editor.go_to_singleton_buffer_point(point, window, cx);
+                    }
+                })
+                .log_err();
+        }
+        Ok(true)
+    } else if open_target.is_dir() {
+        workspace.update(cx, |workspace, cx| {
+            workspace.project().update(cx, |_, cx| {
+                cx.emit(project::Event::ActivateProjectPanel);
+            })
+        })?;
+        Ok(true)
+    } else {
+        Ok(false)
+    }
+}
+
 pub fn entry_label_color(selected: bool) -> Color {
     tab_label_color(selected)
 }
@@ -2195,14 +2286,14 @@ fn restore_serialized_buffer_contents(
 fn serialize_path_key(path_key: &PathKey) -> proto::PathKey {
     proto::PathKey {
         sort_prefix: path_key.sort_prefix,
-        path: path_key.path.to_proto(),
+        path: path_key.path.as_unix_str().to_owned(),
     }
 }
 
 fn deserialize_path_key(path_key: proto::PathKey) -> Option {
     Some(PathKey {
         sort_prefix: path_key.sort_prefix,
-        path: RelPath::from_proto(&path_key.path).ok()?,
+        path: RelPath::from_unix_str(&path_key.path).ok()?.into(),
     })
 }
 
@@ -2248,6 +2339,115 @@ fn chunk_search_range(
     }))
 }
 
+/// Decides what to format based on the `format_on_save` settings of the saved buffers.
+///
+/// In the modifications modes, only lines with unstaged changes are formatted.
+/// When no git diff is available for a buffer, `modifications` skips formatting while `modifications_if_available`
+/// falls back to formatting entire buffers.
+/// When a diff is available but empty, nothing is formatted in either mode.
+fn compute_format_target(
+    buffers: &HashSet>,
+    trigger: FormatTrigger,
+    multi_buffer: &Entity,
+    git_store: &Entity,
+    cx: &App,
+) -> Option {
+    if trigger == FormatTrigger::Manual {
+        return Some(FormatTarget::Buffers(buffers.clone()));
+    }
+
+    let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
+    let git_store = git_store.read(cx);
+
+    let mut fall_back_to_full_format = false;
+    let mut modified_ranges: Vec> = Vec::new();
+
+    for buffer_entity in buffers.iter() {
+        let buffer = buffer_entity.read(cx);
+        let settings = LanguageSettings::for_buffer(buffer, cx);
+        match settings.format_on_save {
+            FormatOnSave::On | FormatOnSave::Off => {
+                return Some(FormatTarget::Buffers(buffers.clone()));
+            }
+            FormatOnSave::Modifications | FormatOnSave::ModificationsIfAvailable => {}
+        }
+
+        let Some(diff_snapshot) = git_store
+            .get_unstaged_diff(buffer.remote_id(), cx)
+            .map(|diff| diff.read(cx).snapshot(cx))
+        else {
+            if settings.format_on_save == FormatOnSave::ModificationsIfAvailable {
+                fall_back_to_full_format = true;
+            }
+            continue;
+        };
+
+        let anchor_ranges = compute_modified_ranges(&buffer.snapshot(), &diff_snapshot);
+        let flat_anchors = anchor_ranges
+            .iter()
+            .flat_map(|range| [range.start, range.end])
+            .collect::>();
+        let multi_buffer_anchors =
+            multi_buffer_snapshot.text_anchors_to_visible_anchors(flat_anchors);
+        for pair in multi_buffer_anchors.chunks_exact(2) {
+            let (Some(start), Some(end)) = (&pair[0], &pair[1]) else {
+                continue;
+            };
+            modified_ranges
+                .push(start.to_point(&multi_buffer_snapshot)..end.to_point(&multi_buffer_snapshot));
+        }
+    }
+
+    if fall_back_to_full_format {
+        Some(FormatTarget::Buffers(buffers.clone()))
+    } else if modified_ranges.is_empty() {
+        None
+    } else {
+        Some(FormatTarget::Ranges(modified_ranges))
+    }
+}
+
+/// Computes the buffer ranges that have unstaged changes, expanded to full lines and
+/// with adjacent hunks merged, for use with format-on-save. An empty result means the
+/// buffer has no formatable modifications.
+fn compute_modified_ranges(
+    buffer_snapshot: &language::BufferSnapshot,
+    diff_snapshot: &buffer_diff::BufferDiffSnapshot,
+) -> Vec> {
+    let mut merged: Vec> = Vec::new();
+    for hunk in diff_snapshot.hunks(buffer_snapshot) {
+        let range = hunk.buffer_range;
+        if range.start.cmp(&range.end, buffer_snapshot).is_eq() {
+            // Deletion-only hunks produce no buffer content to format.
+            continue;
+        }
+        let start_point = range.start.to_point(buffer_snapshot);
+        let end_point = range.end.to_point(buffer_snapshot);
+        let start_row = start_point.row;
+        let end_row = if end_point.column == 0 && end_point.row > start_point.row {
+            end_point.row - 1
+        } else {
+            end_point.row
+        };
+        let line_start = text::Point::new(start_row, 0);
+        let line_end = text::Point::new(end_row, buffer_snapshot.line_len(end_row));
+        let expanded =
+            buffer_snapshot.anchor_before(line_start)..buffer_snapshot.anchor_after(line_end);
+
+        if let Some(last) = merged.last_mut() {
+            let last_end_point = last.end.to_point(buffer_snapshot);
+            if start_row <= last_end_point.row + 1 {
+                if expanded.end.to_point(buffer_snapshot) > last_end_point {
+                    last.end = expanded.end;
+                }
+                continue;
+            }
+        }
+        merged.push(expanded);
+    }
+    merged
+}
+
 #[cfg(test)]
 mod tests {
     use crate::editor_tests::init_test;
@@ -2261,7 +2461,8 @@ mod tests {
     use project::FakeFs;
     use serde_json::json;
     use std::path::{Path, PathBuf};
-    use util::{path, rel_path::RelPath};
+    use util::{path, paths::PathWithPosition, rel_path::RelPath};
+    use workspace::path_link::{OpenTarget, OpenTargetFoundBy};
 
     #[gpui::test]
     fn test_path_for_file(cx: &mut App) {
@@ -2382,6 +2583,95 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_suggested_filename_uses_language_extension_for_untitled_buffer(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| Buffer::local("", cx).with_language(languages::rust_lang(), cx))
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "untitled.rs");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_appends_extension_to_content_title(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| {
+                Buffer::local("sadsdsads\nmore text", cx).with_language(languages::rust_lang(), cx)
+            })
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.tab_content_text(0, cx).as_ref(), "sadsdsads");
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "sadsdsads.rs");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_does_not_duplicate_extension(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| {
+                Buffer::local("main.rs\nfn main() {}", cx).with_language(languages::rust_lang(), cx)
+            })
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "main.rs");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_keeps_content_title_for_plain_text(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| {
+                Buffer::local("shopping list\nmilk", cx)
+                    .with_language(language::PLAIN_TEXT.clone(), cx)
+            })
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_keeps_content_title_without_language(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| cx.new(|cx| Buffer::local("shopping list\nmilk", cx)));
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list");
+        });
+    }
+
     async fn deserialize_editor(
         item_id: ItemId,
         workspace_id: WorkspaceId,
@@ -2813,4 +3103,180 @@ mod tests {
             "Editor::deserialize should not add items to panes as a side effect"
         );
     }
+
+    #[gpui::test]
+    async fn test_open_resolved_target_at_non_ascii_column(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "src": {
+                    "main.rs": "first\naéøbc\n",
+                },
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+        let open_target = OpenTarget::Path(
+            PathWithPosition {
+                path: PathBuf::from(path!("/root/src/main.rs")),
+                row: Some(2),
+                column: Some(4),
+            },
+            false,
+            OpenTargetFoundBy::BackgroundPathResolution,
+        );
+
+        let opened = workspace
+            .update_in(cx, |_, window, cx| {
+                cx.spawn_in(window, async move |workspace, cx| {
+                    open_resolved_target(&workspace, &open_target, cx).await
+                })
+            })
+            .await
+            .expect("opening the target should succeed");
+        assert!(opened, "target should open as a file");
+
+        let editor = workspace.read_with(cx, |workspace, cx| {
+            workspace
+                .active_item(cx)
+                .and_then(|item| item.act_as::(cx))
+                .expect("active item should be an editor")
+        });
+        let cursor = editor.update_in(cx, |editor, _, cx| {
+            editor
+                .selections
+                .newest::(&editor.display_snapshot(cx))
+                .head()
+        });
+        // Column 4 is the fourth character of `aéøbc` (the `b`), which starts at byte 5.
+        assert_eq!(cursor, language::Point::new(1, 5));
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_git_diff(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\nline3\nline4\nline5\nline6\n";
+        // Modify line1 and line5 to create two non-adjacent hunks.
+        let buffer_text = "line0\nMOD1\nline2\nline3\nline4\nMOD5\nline6\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(ranges.len(), 2, "expected 2 non-adjacent ranges");
+
+        buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            let r0 = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
+            let r1 = ranges[1].start.to_point(text_snapshot)..ranges[1].end.to_point(text_snapshot);
+            assert_eq!(r0.start.row, 1, "first hunk should start at row 1");
+            assert_eq!(r0.end.row, 1, "first hunk should end at row 1");
+            assert_eq!(r1.start.row, 5, "second hunk should start at row 5");
+            assert_eq!(r1.end.row, 5, "second hunk should end at row 5");
+        });
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_unchanged_buffer(cx: &mut gpui::TestAppContext) {
+        let buffer_text = "line0\nline1\nline2\n";
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(
+                    buffer_text,
+                    &buffer.text_snapshot(),
+                    cx,
+                )
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges,
+            Vec::new(),
+            "buffer that matches its diff base should produce no modified ranges"
+        );
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_deletion_only(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\n";
+        // Buffer has line1 deleted (pure deletion).
+        let buffer_text = "line0\nline2\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        // Verify the diff has a deletion hunk.
+        let hunk_count = buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            diff_snapshot.hunks(text_snapshot).count()
+        });
+        assert!(hunk_count > 0, "diff should have hunks");
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges,
+            Vec::new(),
+            "deletion-only hunks should be skipped, leaving no ranges"
+        );
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_adjacent_hunks(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\nline3\nline4\n";
+        // Modify lines 2 and 3 which are adjacent; they should merge into one range.
+        let buffer_text = "line0\nline1\nMOD2\nMOD3\nline4\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges.len(),
+            1,
+            "adjacent hunks (rows 2 and 3) should be merged into one range"
+        );
+        buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            let r = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
+            assert_eq!(r.start.row, 2, "merged range should start at row 2");
+            assert_eq!(r.end.row, 3, "merged range should end at row 3");
+        });
+    }
 }
diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs
index 879384fb6e65fd..0c4cbb3667a1d2 100644
--- a/crates/editor/src/mouse_context_menu.rs
+++ b/crates/editor/src/mouse_context_menu.rs
@@ -256,10 +256,13 @@ pub fn deploy_context_menu(
                     run_to_cursor || (evaluate_selection && has_selections),
                     |builder| builder.separator(),
                 )
-                .action("Go to Definition", Box::new(GoToDefinition))
+                .action("Go to Definition", Box::new(GoToDefinition::default()))
                 .action("Go to Declaration", Box::new(GoToDeclaration))
                 .action("Go to Type Definition", Box::new(GoToTypeDefinition))
-                .action("Go to Implementation", Box::new(GoToImplementation))
+                .action(
+                    "Go to Implementation",
+                    Box::new(GoToImplementation::default()),
+                )
                 .action(
                     "Find All References",
                     Box::new(FindAllReferences::default()),
diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs
index 5742c9d20ce2db..92b1a30cf2231b 100644
--- a/crates/editor/src/movement.rs
+++ b/crates/editor/src/movement.rs
@@ -582,6 +582,99 @@ pub fn end_of_paragraph(
     map.max_point()
 }
 
+/// Returns whether `row` is part of a comment paragraph: a line whose first
+/// non-whitespace character lies within a comment scope and which contains at
+/// least one alphanumeric character.
+///
+/// This intentionally excludes:
+/// - blank lines and code lines,
+/// - end-of-line comments preceded by code (the first non-whitespace character
+///   is then code, not a comment),
+/// - "blank"/divider comment lines such as a bare `//` or `// -----` (no
+///   alphanumeric content), which act as paragraph separators.
+fn is_comment_paragraph_line(snapshot: &MultiBufferSnapshot, row: u32) -> bool {
+    let buffer_row = MultiBufferRow(row);
+    if snapshot.is_line_blank(buffer_row) {
+        return false;
+    }
+    let indent_len = snapshot.indent_size_for_line(buffer_row).len;
+    let indent_end = Point::new(row, indent_len);
+    let in_comment = snapshot.language_scope_at(indent_end).is_some_and(|scope| {
+        matches!(
+            scope.override_name(),
+            Some("comment") | Some("comment.inclusive")
+        )
+    });
+    if !in_comment {
+        return false;
+    }
+    let line_end = Point::new(row, snapshot.line_len(buffer_row));
+    snapshot
+        .text_for_range(indent_end..line_end)
+        .flat_map(|chunk| chunk.chars())
+        .any(|c| c.is_alphanumeric())
+}
+
+/// Returns the position of the first non-whitespace character of the next or
+/// previous comment paragraph, relative to `from`.
+///
+/// A comment paragraph is a run of consecutive comment lines (see
+/// [`is_comment_paragraph_line`]); paragraphs are separated by blank lines, code
+/// lines, and blank/divider comment lines. If no such paragraph exists in the
+/// requested direction, `from` is returned unchanged.
+///
+/// Both directions always move to a *different* paragraph than the one the
+/// caret is in: when the caret is inside a comment paragraph, the entire
+/// current paragraph is skipped, so `Prev` lands on the previous paragraph's
+/// start rather than the current paragraph's own start.
+pub fn comment_paragraph(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    direction: Direction,
+) -> DisplayPoint {
+    let snapshot = map.buffer_snapshot();
+    let from_point = from.to_point(map);
+    let max_row = snapshot.max_row().0;
+
+    let is_paragraph_start = |row: u32| {
+        is_comment_paragraph_line(snapshot, row)
+            && (row == 0 || !is_comment_paragraph_line(snapshot, row - 1))
+    };
+    let paragraph_start_point =
+        |row: u32| Point::new(row, snapshot.indent_size_for_line(MultiBufferRow(row)).len);
+
+    let target = match direction {
+        Direction::Next => (from_point.row..=max_row).find_map(|row| {
+            let point = paragraph_start_point(row);
+            (point > from_point && is_paragraph_start(row)).then_some(point)
+        }),
+        Direction::Prev => {
+            // If the caret is within a comment paragraph, skip over the whole
+            // current paragraph so we land on the *previous* paragraph rather
+            // than stopping at the current paragraph's own start.
+            let mut boundary_row = from_point.row;
+            if is_comment_paragraph_line(snapshot, boundary_row) {
+                while boundary_row > 0 && is_comment_paragraph_line(snapshot, boundary_row - 1) {
+                    boundary_row -= 1;
+                }
+                (0..boundary_row)
+                    .rev()
+                    .find_map(|row| is_paragraph_start(row).then(|| paragraph_start_point(row)))
+            } else {
+                (0..=from_point.row).rev().find_map(|row| {
+                    let point = paragraph_start_point(row);
+                    (point < from_point && is_paragraph_start(row)).then_some(point)
+                })
+            }
+        }
+    };
+
+    match target {
+        Some(point) => map.clip_point(point.to_display_point(map), Bias::Right),
+        None => from,
+    }
+}
+
 pub fn start_of_excerpt(
     map: &DisplaySnapshot,
     display_point: DisplayPoint,
diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs
index 4f9880c438bdf6..3d4b42a3054242 100644
--- a/crates/editor/src/navigation.rs
+++ b/crates/editor/src/navigation.rs
@@ -608,6 +608,68 @@ impl Editor {
         })
     }
 
+    pub fn move_to_next_comment_paragraph(
+        &mut self,
+        _: &MoveToNextCommentParagraph,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate();
+            return;
+        }
+        // Keep the destination paragraph near the top of the viewport so the
+        // whole paragraph below the caret stays visible after a jump.
+        self.change_selections(
+            SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    selection.collapse_to(
+                        movement::comment_paragraph(
+                            map,
+                            selection.head(),
+                            workspace::searchable::Direction::Next,
+                        ),
+                        SelectionGoal::None,
+                    )
+                });
+            },
+        )
+    }
+
+    pub fn move_to_previous_comment_paragraph(
+        &mut self,
+        _: &MoveToPreviousCommentParagraph,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate();
+            return;
+        }
+        // Keep the destination paragraph near the top of the viewport so the
+        // whole paragraph below the caret stays visible after a jump.
+        self.change_selections(
+            SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    selection.collapse_to(
+                        movement::comment_paragraph(
+                            map,
+                            selection.head(),
+                            workspace::searchable::Direction::Prev,
+                        ),
+                        SelectionGoal::None,
+                    )
+                });
+            },
+        )
+    }
+
     pub fn select_to_start_of_paragraph(
         &mut self,
         _: &SelectToStartOfParagraph,
@@ -1236,6 +1298,62 @@ impl Editor {
         }))
     }
 
+    /// Runs the LSP go-to query for `kind` (definition / declaration / type /
+    /// implementation) against the symbol under the cursor and returns the raw
+    /// target [`Location`]s. The go-to counterpart to
+    /// [`Self::find_all_references_locations`]; used by the LSP location pickers.
+    pub fn definition_locations_of_kind(
+        &mut self,
+        kind: GotoDefinitionKind,
+        cx: &mut Context,
+    ) -> Option>>> {
+        let provider = self.semantics_provider.clone()?;
+        let selection = self.selections.newest_anchor();
+        let multi_buffer = self.buffer.read(cx);
+        let multi_buffer_snapshot = multi_buffer.snapshot(cx);
+        let head = selection
+            .map(|anchor| anchor.to_offset(&multi_buffer_snapshot))
+            .head();
+
+        let (buffer, head) = multi_buffer.text_anchor_for_position(head, cx)?;
+        let definitions = provider.definitions(&buffer, head, kind, cx)?;
+        Some(cx.spawn(async move |editor, cx| {
+            let definitions = definitions.await?.unwrap_or_default();
+            // Drop a result that points back at the cursor, matching
+            // `go_to_definition_of_kind` (otherwise the picker lists the symbol
+            // you invoked it on).
+            editor.update(cx, |_, cx| {
+                definitions
+                    .into_iter()
+                    .filter(|link| hover_links::exclude_link_to_position(&buffer, &head, link, cx))
+                    .map(|link| link.target)
+                    .collect()
+            })
+        }))
+    }
+
+    /// Runs an LSP "find all references" query for the symbol under the cursor
+    /// and returns the raw [`Location`]s. Unlike [`Self::find_all_references`],
+    /// this does not group the results or open any UI
+    pub fn find_all_references_locations(
+        &mut self,
+        project: &Entity,
+        cx: &mut Context,
+    ) -> Option>>> {
+        let selection = self.selections.newest_anchor();
+        let multi_buffer = self.buffer.read(cx);
+        let multi_buffer_snapshot = multi_buffer.snapshot(cx);
+        let head = selection
+            .map(|anchor| anchor.to_offset(&multi_buffer_snapshot))
+            .head();
+
+        let (buffer, head) = multi_buffer.text_anchor_for_position(head, cx)?;
+        let references = project.update(cx, |project, cx| project.references(&buffer, head, cx));
+        // Keep every reference, including the one under the cursor, to match the
+        // default `find_all_references` multibuffer (`always_open_multibuffer`).
+        Some(cx.spawn(async move |_, _| Ok(references.await?.unwrap_or_default())))
+    }
+
     pub fn find_all_references(
         &mut self,
         action: &FindAllReferences,
@@ -2039,6 +2157,25 @@ impl Editor {
         self.go_to_symbol_by_offset(window, cx, -1).detach();
     }
 
+    /// Opens `location` and jumps to it through the same path as
+    /// go-to-definition, so selection, autoscroll, and jumplist tagging all
+    /// match. `split` opens it in the adjacent pane. Called on the editor the
+    /// jump originates from.
+    pub fn open_location(
+        &mut self,
+        location: Location,
+        split: bool,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        let origin = self.navigation_entry(self.selections.newest_anchor().head(), cx);
+        let link = HoverLink::Text(LocationLink {
+            origin: None,
+            target: location,
+        });
+        self.navigate_to_hover_links(None, vec![link], origin, split, window, cx)
+    }
+
     /// Opens a multibuffer with the given project locations in it.
     pub(super) fn open_locations_in_multibuffer(
         workspace: &mut Workspace,
@@ -2318,10 +2455,15 @@ impl Editor {
         cx: &mut Context,
     ) {
         let multibuffer = self.buffer().read(cx);
-        if !multibuffer.is_singleton() {
+        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;
         };
-        let anchor_range = range.to_anchors(&multibuffer.snapshot(cx));
         self.change_selections(
             SelectionEffects::scroll(Autoscroll::for_go_to_definition(
                 self.cursor_top_offset(cx),
@@ -2330,7 +2472,7 @@ impl Editor {
             .nav_history(record_nav_history),
             window,
             cx,
-            |s| s.select_anchor_ranges([anchor_range]),
+            |s| s.select_anchor_ranges([start..end]),
         );
     }
 
diff --git a/crates/editor/src/rewrap.rs b/crates/editor/src/rewrap.rs
index 50647729d32e52..9f39af1fcc06f1 100644
--- a/crates/editor/src/rewrap.rs
+++ b/crates/editor/src/rewrap.rs
@@ -430,8 +430,13 @@ fn is_grapheme_ideographic(text: &str) -> bool {
     text.chars().any(is_char_ideographic)
 }
 
+fn is_non_breaking_whitespace(character: char) -> bool {
+    matches!(character, '\u{00A0}' | '\u{2007}' | '\u{202F}')
+}
+
 fn is_grapheme_whitespace(text: &str) -> bool {
-    text.chars().any(|x| x.is_whitespace())
+    text.chars()
+        .any(|character| character.is_whitespace() && !is_non_breaking_whitespace(character))
 }
 
 fn should_stay_with_preceding_ideograph(text: &str) -> bool {
@@ -690,7 +695,23 @@ mod tests {
                     word("笔", 1),
                 ],
             ),
-            (" mutton", &[whitespace(" ", 1), word("mutton", 6)]),
+            (
+                "\u{2003}mutton",
+                &[whitespace("\u{2003}", 1), word("mutton", 6)],
+            ),
+            (
+                "a\u{2009}b\u{2009}c",
+                &[
+                    word("a", 1),
+                    whitespace("\u{2009}", 1),
+                    word("b", 1),
+                    whitespace("\u{2009}", 1),
+                    word("c", 1),
+                ],
+            ),
+            ("a\u{a0}b\u{a0}c", &[word("a\u{a0}b\u{a0}c", 5)]),
+            ("a\u{2007}b\u{2007}c", &[word("a\u{2007}b\u{2007}c", 5)]),
+            ("a\u{202f}b\u{202f}c", &[word("a\u{202f}b\u{202f}c", 5)]),
         ];
 
         fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> {
@@ -778,5 +799,19 @@ mod tests {
             ),
             format!("foo{}bar", '\u{2009}')
         );
+        for non_breaking_space in ['\u{a0}', '\u{2007}', '\u{202f}'] {
+            let input = format!("a{0}a{0}a{0}a{0}a", non_breaking_space);
+            assert_eq!(
+                wrap_with_prefix(
+                    String::new(),
+                    String::new(),
+                    input.clone(),
+                    4,
+                    NonZeroU32::new(4).unwrap(),
+                    false,
+                ),
+                input
+            );
+        }
     }
 }
diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs
index 956e4dd2e09484..13433bfcb4a1af 100644
--- a/crates/editor/src/runnables.rs
+++ b/crates/editor/src/runnables.rs
@@ -537,6 +537,7 @@ impl Editor {
             let quick_launch = match e {
                 ClickEvent::Keyboard(_) => true,
                 ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
+                ClickEvent::Touch(_) => true,
             };
 
             window.focus(&editor.focus_handle(cx), cx);
@@ -727,12 +728,21 @@ mod tests {
     use util::rel_path::rel_path;
 
     use crate::{
-        Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount,
+        Editor, UPDATE_DEBOUNCE,
+        editor_tests::{init_test, update_test_editor_settings},
+        scroll::scroll_amount::ScrollAmount,
         test::build_editor_with_project,
     };
 
     const FAKE_LSP_NAME: &str = "the-fake-language-server";
 
+    fn init_runnables_test(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        update_test_editor_settings(cx, &|settings| {
+            settings.gutter.get_or_insert_default().runnables = Some(true);
+        });
+    }
+
     struct TestRustContextProvider;
 
     impl ContextProvider for TestRustContextProvider {
@@ -823,7 +833,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_multi_buffer_runnables_on_scroll(cx: &mut TestAppContext) {
-        init_test(cx, |_| {});
+        init_runnables_test(cx);
 
         let padding_lines = 50;
         let mut first_rs = String::from("fn main() {\n    println!(\"hello\");\n}\n");
@@ -966,7 +976,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_lsp_runnables_removed_after_edit(cx: &mut TestAppContext) {
-        init_test(cx, |_| {});
+        init_runnables_test(cx);
 
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
@@ -1088,7 +1098,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_no_runnables_for_unsaved_buffer(cx: &mut TestAppContext) {
-        init_test(cx, |_| {});
+        init_runnables_test(cx);
 
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(path!("/project"), json!({})).await;
@@ -1181,7 +1191,7 @@ mod tests {
     // a task template that uses the shell program and args.
     #[gpui::test]
     async fn test_shell_runnable_produces_correct_task_template(cx: &mut TestAppContext) {
-        init_test(cx, |_| {});
+        init_runnables_test(cx);
 
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs
index ec7f9036c4a2d4..dcd96c675c50e9 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,
-    MultiBufferSnapshot, RowExt, SizingBehavior, ToPoint,
+    MultiBufferSnapshot, RowExt, SelectionEffects, SizingBehavior, ToPoint,
     display_map::{DisplaySnapshot, ToDisplayPoint},
     hover_popover::hide_hover,
     persistence::EditorDb,
@@ -945,6 +945,63 @@ impl Editor {
         self.set_scroll_position(new_position, window, cx);
     }
 
+    pub fn scroll_screen_with_cursor_margin(
+        &mut self,
+        amount: &ScrollAmount,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.scroll_screen(amount, window, cx);
+
+        let Some(visible_line_count) = self.visible_line_count() else {
+            return;
+        };
+        let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
+        let top = self
+            .scroll_manager
+            .scroll_top_display_point(&display_snapshot, cx);
+        let vertical_scroll_margin =
+            (self.vertical_scroll_margin() as u32).min(visible_line_count as u32 / 2);
+
+        let max_point = display_snapshot.max_point();
+        let min_row = if top.row().0 == 0 {
+            DisplayRow(0)
+        } else {
+            DisplayRow(top.row().0 + vertical_scroll_margin)
+        };
+        let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
+            max_point.row()
+        } else {
+            DisplayRow(
+                (top.row().0 + visible_line_count as u32)
+                    .saturating_sub(1 + vertical_scroll_margin),
+            )
+        };
+
+        self.change_selections(
+            SelectionEffects::no_scroll().nav_history(false),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    let head = selection.head();
+                    let new_row = if head.row() < min_row {
+                        min_row
+                    } else if head.row() > max_row {
+                        max_row
+                    } else {
+                        head.row()
+                    };
+                    if new_row != head.row() {
+                        let new_head =
+                            map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left);
+                        selection.collapse_to(new_head, selection.goal);
+                    }
+                })
+            },
+        );
+    }
+
     /// Returns an ordering. The newest selection is:
     ///     Ordering::Equal => on screen
     ///     Ordering::Less => above or to the left of the screen
diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs
index 1d5ba4b5261346..afb261305ec982 100644
--- a/crates/editor/src/selection.rs
+++ b/crates/editor/src/selection.rs
@@ -1442,14 +1442,20 @@ impl Editor {
             let selections = self
                 .selections
                 .all::(&self.display_snapshot(cx));
+            // `select` below resets the selections' granularity to `Character`, since it's the
+            // funnel every wholesale selection replacement goes through. When extending, the
+            // granularity established by the selection gesture that started the extension must
+            // survive that reset instead of being overwritten by `pending_mode`.
+            let select_mode = if self.selections.is_extending() {
+                self.selections.select_mode().clone()
+            } else {
+                pending_mode
+            };
             self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select(selections);
                 s.clear_pending();
-                if s.is_extending() {
-                    s.set_is_extending(false);
-                } else {
-                    s.set_select_mode(pending_mode);
-                }
+                s.set_is_extending(false);
+                s.set_select_mode(select_mode);
             });
         }
     }
diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs
index 33ffdfca815368..0e2a607a445369 100644
--- a/crates/editor/src/selections_collection.rs
+++ b/crates/editor/src/selections_collection.rs
@@ -7,7 +7,7 @@ use std::{
 use gpui::Pixels;
 use itertools::{Either, Itertools as _};
 use language::{Bias, Point, PointUtf16, Selection, SelectionGoal};
-use multi_buffer::{MultiBufferDimension, MultiBufferOffset};
+use multi_buffer::{MultiBufferDimension, MultiBufferOffset, ToPoint};
 use util::post_inc;
 
 use crate::{
@@ -212,6 +212,26 @@ impl SelectionsCollection {
         }
     }
 
+    pub fn disjoint_in_row_range(
+        &self,
+        range: Range,
+        snapshot: &DisplaySnapshot,
+    ) -> Vec>
+    where
+        D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord + std::fmt::Debug,
+    {
+        let buffer = snapshot.buffer_snapshot();
+        let start_row = range.start.to_point(buffer).row;
+        let end_row = range.end.to_point(buffer).row;
+        let start_ix = self
+            .disjoint
+            .partition_point(|probe| probe.end.to_point(buffer).row < start_row);
+        let end_ix = self
+            .disjoint
+            .partition_point(|probe| probe.start.to_point(buffer).row <= end_row);
+        resolve_selections_wrapping_blocks(&self.disjoint[start_ix..end_ix], snapshot).collect()
+    }
+
     pub fn disjoint_in_range(
         &self,
         range: Range,
@@ -220,19 +240,13 @@ impl SelectionsCollection {
     where
         D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord + std::fmt::Debug,
     {
-        let start_ix = match self
+        let buffer = snapshot.buffer_snapshot();
+        let start_ix = self
             .disjoint
-            .binary_search_by(|probe| probe.end.cmp(&range.start, snapshot.buffer_snapshot()))
-        {
-            Ok(ix) | Err(ix) => ix,
-        };
-        let end_ix = match self
+            .partition_point(|probe| probe.end.cmp(&range.start, buffer).is_lt());
+        let end_ix = self
             .disjoint
-            .binary_search_by(|probe| probe.start.cmp(&range.end, snapshot.buffer_snapshot()))
-        {
-            Ok(ix) => ix + 1,
-            Err(ix) => ix,
-        };
+            .partition_point(|probe| probe.start.cmp(&range.end, buffer).is_le());
         resolve_selections_wrapping_blocks(&self.disjoint[start_ix..end_ix], snapshot).collect()
     }
 
@@ -843,6 +857,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
                 .map(|selection| selection_to_anchor_selection(selection, self.snapshot)),
         );
         self.collection.pending = None;
+        self.collection.select_mode = SelectMode::Character;
         self.selections_changed = true;
     }
 
@@ -1351,6 +1366,125 @@ mod tests {
     use settings::SettingsStore;
     use std::sync::Arc;
 
+    fn row_range_snapshot(cx: &mut gpui::TestAppContext, text: &str) -> DisplaySnapshot {
+        cx.update(|cx| {
+            let settings = SettingsStore::test(cx);
+            cx.set_global(settings);
+            crate::init(cx);
+        });
+        let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx));
+        let display_map = cx.new(|cx| {
+            DisplayMap::new(
+                buffer,
+                test_font(),
+                px(14.),
+                None,
+                1,
+                1,
+                FoldPlaceholder::test(),
+                DiagnosticSeverity::Warning,
+                cx,
+            )
+        });
+        display_map.update(cx, |map, cx| map.snapshot(cx))
+    }
+
+    fn row_range_collection(
+        offset_ranges: impl IntoIterator>,
+        buffer_snapshot: &MultiBufferSnapshot,
+    ) -> SelectionsCollection {
+        let selections = offset_ranges
+            .into_iter()
+            .enumerate()
+            .map(|(id, range)| {
+                selection_to_anchor_selection(
+                    Selection {
+                        id,
+                        start: MultiBufferOffset(range.start),
+                        end: MultiBufferOffset(range.end),
+                        reversed: false,
+                        goal: SelectionGoal::None,
+                    },
+                    buffer_snapshot,
+                )
+            })
+            .collect::>();
+        let mut collection = SelectionsCollection::new();
+        collection.disjoint = Arc::from(selections);
+        collection.pending = None;
+        collection
+    }
+
+    /// `disjoint_in_row_range` selects by whole rows, so a selection sharing a queried row must be
+    /// returned even when its columns don't overlap the queried range. `disjoint_in_range` compares
+    /// exact offsets and would miss this case.
+    #[gpui::test]
+    fn disjoint_in_row_range_matches_whole_row(cx: &mut gpui::TestAppContext) {
+        let snapshot = row_range_snapshot(cx, "aaaa\nbbbbbbbb\ncccc");
+        let buffer_snapshot = snapshot.buffer_snapshot();
+
+        // A selection on row 1 spanning columns 3..5 (buffer offsets 8..10).
+        let collection = row_range_collection([8..10], buffer_snapshot);
+
+        // Query row 1 at columns 0..1, entirely before the selection's columns.
+        let range = buffer_snapshot.anchor_before(MultiBufferOffset(5))
+            ..buffer_snapshot.anchor_before(MultiBufferOffset(6));
+        let result = collection.disjoint_in_row_range::(range, &snapshot);
+
+        assert_eq!(
+            result.len(),
+            1,
+            "row range should include the selection sharing the queried row"
+        );
+        assert_eq!(result[0].start, Point::new(1, 3));
+        assert_eq!(result[0].end, Point::new(1, 5));
+    }
+
+    /// A selection on a row outside the queried row range must not be returned. This guards the
+    /// row-overlap boundary against becoming over-inclusive.
+    #[gpui::test]
+    fn disjoint_in_row_range_excludes_other_rows(cx: &mut gpui::TestAppContext) {
+        let snapshot = row_range_snapshot(cx, "aaaa\nbbbbbbbb\ncccc");
+        let buffer_snapshot = snapshot.buffer_snapshot();
+
+        // A selection on row 2 (buffer offsets 14..16).
+        let collection = row_range_collection([14..16], buffer_snapshot);
+
+        // Query only row 0, two rows away from the selection.
+        let range = buffer_snapshot.anchor_before(MultiBufferOffset(0))
+            ..buffer_snapshot.anchor_before(MultiBufferOffset(1));
+        let result = collection.disjoint_in_row_range::(range, &snapshot);
+
+        assert!(
+            result.is_empty(),
+            "row range should exclude a selection on a non-queried row"
+        );
+    }
+
+    /// A selection spanning multiple rows must be returned when the query touches any of those rows,
+    /// not just when the query shares the selection's start or end row.
+    #[gpui::test]
+    fn disjoint_in_row_range_matches_interior_row(cx: &mut gpui::TestAppContext) {
+        let snapshot = row_range_snapshot(cx, "aaaa\nbbbb\ncccc\ndddd");
+        let buffer_snapshot = snapshot.buffer_snapshot();
+
+        // A selection spanning row 1 column 1 through row 3 column 2 (buffer offsets 6..17).
+        let collection = row_range_collection([6..17], buffer_snapshot);
+
+        // Query only row 2, an interior row of the selection.
+        let range = buffer_snapshot.anchor_before(MultiBufferOffset(11))
+            ..buffer_snapshot.anchor_before(MultiBufferOffset(12));
+        let result = collection.disjoint_in_row_range::(range, &snapshot);
+
+        assert_eq!(
+            result.len(),
+            1,
+            "row range should include a selection whose interior row is queried"
+        );
+        assert_eq!(result[0].start, Point::new(1, 1));
+        assert_eq!(result[0].end, Point::new(3, 2));
+    }
+
     #[gpui::test(iterations = 20)]
     fn fast_and_slow_selection_resolution_match_without_collapsed_content(
         cx: &mut gpui::TestAppContext,
diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs
index e568b886f73af5..56a71471702c5b 100644
--- a/crates/editor/src/split.rs
+++ b/crates/editor/src/split.rs
@@ -3,27 +3,27 @@ use std::{
     sync::Arc,
 };
 
-use buffer_diff::{BufferDiff, BufferDiffSnapshot};
+use buffer_diff::{BufferDiff, BufferDiffSnapshot, DiffHunkStatus};
 use collections::HashMap;
 
+use fs::Fs;
 use gpui::{
-    Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Pixels, Subscription,
-    WeakEntity, canvas,
+    Action, AnyElement, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, WeakEntity,
+    canvas, prelude::*,
 };
-use itertools::Itertools;
+
 use language::{Buffer, Capability, HighlightedText};
 use multi_buffer::{
     Anchor, AnchorRangeExt as _, BufferOffset, ExcerptRange, ExpandExcerptDirection, MultiBuffer,
-    MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey,
+    MultiBufferPoint, MultiBufferSnapshot, PathKey,
 };
 use project::Project;
 use rope::Point;
-use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore};
+use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore, update_settings_file};
 use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _};
-use ui::{
-    App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render,
-    Styled as _, Window, div,
-};
+
+use ui::{Toggleable as _, Tooltip, prelude::*, render_modifiers};
+use util::ResultExt as _;
 
 use crate::{
     display_map::CompanionExcerptPatch,
@@ -37,11 +37,12 @@ use workspace::{
 };
 
 use crate::{
-    Autoscroll, Editor, EditorEvent, EditorSettings, RenderDiffHunkControlsFn, ToggleSoftWrap,
+    Autoscroll, DiffHunkDelegate, Editor, EditorEvent, EditorSettings, ResolvedDiffHunks,
+    ToggleSoftWrap, UncommittedDiffHunkDelegate,
     actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint},
     display_map::Companion,
 };
-use zed_actions::assistant::InlineAssist;
+use zed_actions::{OpenSettingsAt, assistant::InlineAssist};
 
 pub(crate) fn patches_for_lhs_range(
     rhs_snapshot: &MultiBufferSnapshot,
@@ -150,32 +151,97 @@ fn translate_lhs_selections_to_rhs(
     translated
 }
 
-fn translate_lhs_hunks_to_rhs(
-    lhs_hunks: &[MultiBufferDiffHunk],
-    splittable: &SplittableEditor,
-    cx: &App,
-) -> Vec {
-    let Some(lhs) = &splittable.lhs else {
-        return vec![];
-    };
-    let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx);
-    let rhs_snapshot = splittable.rhs_multibuffer.read(cx).snapshot(cx);
-    let rhs_hunks: Vec = rhs_snapshot.diff_hunks().collect();
+struct SplitLhsDiffHunkDelegate {
+    splittable: WeakEntity,
+}
 
-    let mut translated = Vec::new();
-    for lhs_hunk in lhs_hunks {
-        let Some(diff) = lhs_snapshot.diff_for_buffer_id(lhs_hunk.buffer_id) else {
-            continue;
+impl DiffHunkDelegate for SplitLhsDiffHunkDelegate {
+    fn toggle(
+        &self,
+        hunks: Vec,
+        _editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.splittable
+            .update(cx, |splittable, cx| {
+                splittable.rhs_editor.update(cx, |editor, cx| {
+                    let delegate = editor.diff_hunk_delegate();
+                    delegate.toggle(hunks, editor, window, cx);
+                });
+            })
+            .log_err();
+    }
+
+    fn stage_or_unstage(
+        &self,
+        stage: bool,
+        hunks: Vec,
+        _editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.splittable
+            .update(cx, |splittable, cx| {
+                splittable.rhs_editor.update(cx, |editor, cx| {
+                    let delegate = editor.diff_hunk_delegate();
+                    delegate.stage_or_unstage(stage, hunks, editor, window, cx);
+                });
+            })
+            .log_err();
+    }
+
+    fn restore(
+        &self,
+        hunks: Vec,
+        _editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.splittable
+            .update(cx, |splittable, cx| {
+                splittable.rhs_editor.update(cx, |editor, cx| {
+                    let delegate = editor.diff_hunk_delegate();
+                    delegate.restore(hunks, editor, window, cx);
+                });
+            })
+            .log_err();
+    }
+
+    fn render_hunk_controls(
+        &self,
+        row: u32,
+        status: &DiffHunkStatus,
+        hunk_range: Range,
+        is_created_file: bool,
+        line_height: Pixels,
+        editor: &Entity,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement {
+        let Some(splittable) = self.splittable.upgrade() else {
+            return gpui::Empty.into_any_element();
         };
-        let rhs_buffer_id = diff.buffer_id();
-        if let Some(rhs_hunk) = rhs_hunks.iter().find(|rhs_hunk| {
-            rhs_hunk.buffer_id == rhs_buffer_id
-                && rhs_hunk.diff_base_byte_range == lhs_hunk.diff_base_byte_range
-        }) {
-            translated.push(rhs_hunk.clone());
-        }
+        let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate();
+        delegate.render_hunk_controls(
+            row,
+            status,
+            hunk_range,
+            is_created_file,
+            line_height,
+            editor,
+            window,
+            cx,
+        )
+    }
+
+    fn render_hunk_as_staged(&self, status: &DiffHunkStatus, cx: &App) -> bool {
+        let Some(splittable) = self.splittable.upgrade() else {
+            return false;
+        };
+        let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate();
+        delegate.render_hunk_as_staged(status, cx)
     }
-    translated
 }
 
 fn patches_for_range(
@@ -401,6 +467,124 @@ fn patch_for_excerpt(
 #[action(namespace = editor)]
 pub struct ToggleSplitDiff;
 
+/// Unified/split diff view toggle buttons, shared by the toolbars of every
+/// diff view (project diff, branch diff, solo diff) so they can't drift apart.
+#[derive(gpui::IntoElement)]
+pub struct DiffStyleControls {
+    splittable_editor: Entity,
+}
+
+impl DiffStyleControls {
+    pub fn new(splittable_editor: Entity) -> Self {
+        Self { splittable_editor }
+    }
+
+    fn set_diff_view_style(
+        splittable_editor: &Entity,
+        diff_view_style: DiffViewStyle,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        update_settings_file(::global(cx), cx, move |settings, _| {
+            settings.editor.diff_view_style = Some(diff_view_style);
+        });
+
+        splittable_editor.update(cx, |editor, cx| {
+            if editor.diff_view_style() != diff_view_style {
+                editor.toggle_split(&ToggleSplitDiff, window, cx);
+            }
+        });
+    }
+}
+
+impl RenderOnce for DiffStyleControls {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
+        let editor = self.splittable_editor.read(cx);
+        let diff_view_style = editor.diff_view_style();
+        let is_split_set = diff_view_style == DiffViewStyle::Split;
+        let is_split_pending = is_split_set && !editor.is_split();
+        let min_columns = EditorSettings::get_global(cx).minimum_split_diff_width as u32;
+
+        let split_icon = if is_split_pending {
+            IconName::DiffSplitAuto
+        } else {
+            IconName::DiffSplit
+        };
+
+        h_flex()
+            .gap_1()
+            .child(
+                IconButton::new("diff-style-unified", IconName::DiffUnified)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(diff_view_style == DiffViewStyle::Unified)
+                    .tooltip(Tooltip::text("Unified"))
+                    .on_click({
+                        let splittable_editor = self.splittable_editor.clone();
+                        move |_, window, cx| {
+                            Self::set_diff_view_style(
+                                &splittable_editor,
+                                DiffViewStyle::Unified,
+                                window,
+                                cx,
+                            );
+                        }
+                    }),
+            )
+            .child(
+                IconButton::new("diff-style-split", split_icon)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(is_split_set)
+                    .tooltip(Tooltip::element(move |_, cx| {
+                        let message = if is_split_pending {
+                            format!("Split when wider than {} columns", min_columns).into()
+                        } else {
+                            SharedString::from("Split")
+                        };
+
+                        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_element()
+                    }))
+                    .on_click({
+                        let splittable_editor = self.splittable_editor.clone();
+                        move |_, window, cx| {
+                            if window.modifiers().secondary() {
+                                window.dispatch_action(
+                                    OpenSettingsAt {
+                                        path: "minimum_split_diff_width".to_string(),
+                                        target: None,
+                                    }
+                                    .boxed_clone(),
+                                    cx,
+                                );
+                            } else {
+                                Self::set_diff_view_style(
+                                    &splittable_editor,
+                                    DiffViewStyle::Split,
+                                    window,
+                                    cx,
+                                );
+                            }
+                        }
+                    }),
+            )
+    }
+}
+
 pub struct SplittableEditor {
     rhs_multibuffer: Entity,
     rhs_editor: Entity,
@@ -452,28 +636,13 @@ impl SplittableEditor {
         self.lhs.is_some()
     }
 
-    pub fn set_render_diff_hunk_controls(
+    pub fn set_diff_hunk_delegate(
         &self,
-        render_diff_hunk_controls: RenderDiffHunkControlsFn,
+        delegate: Option>,
         cx: &mut Context,
     ) {
-        self.update_editors(cx, |editor, cx| {
-            editor.set_render_diff_hunk_controls(render_diff_hunk_controls.clone(), cx);
-        });
-    }
-
-    pub fn disable_diff_hunk_controls(&self, cx: &mut Context) {
-        let empty_controls = Arc::new(|_, _: &_, _, _, _, _: &_, _: &mut _, _: &mut _| {
-            gpui::Empty.into_any_element()
-        });
-        self.update_editors(cx, |editor, cx| {
-            editor.set_render_diff_hunk_controls(empty_controls.clone(), cx);
-        });
-    }
-
-    pub fn set_render_diff_hunks_as_unstaged(&self, cx: &mut Context) {
-        self.update_editors(cx, |editor, cx| {
-            editor.set_render_diff_hunks_as_unstaged(true, cx);
+        self.rhs_editor.update(cx, |editor, cx| {
+            editor.set_diff_hunk_delegate(delegate, cx);
         });
     }
 
@@ -514,7 +683,7 @@ impl SplittableEditor {
             editor.disable_inline_diagnostics();
             editor.disable_mouse_wheel_zoom();
             editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx);
-            editor.start_temporary_diff_override();
+            editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx);
             editor
         });
         // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs
@@ -612,15 +781,16 @@ impl SplittableEditor {
             multibuffer
         });
 
-        let render_diff_hunk_controls = self.rhs_editor.read(cx).render_diff_hunk_controls.clone();
-        let render_diff_hunks_as_unstaged = self.rhs_editor.read(cx).render_diff_hunks_as_unstaged;
+        let splittable = cx.weak_entity();
         let lhs_editor = cx.new(|cx| {
             let mut editor =
                 Editor::for_multibuffer(lhs_multibuffer.clone(), Some(project.clone()), window, cx);
-            editor.set_render_diff_hunks_as_unstaged(render_diff_hunks_as_unstaged, cx);
             editor.set_number_deleted_lines(true, cx);
             editor.set_delegate_expand_excerpts(true);
-            editor.set_delegate_stage_and_restore(true);
+            editor.set_diff_hunk_delegate(
+                Some(Arc::new(SplitLhsDiffHunkDelegate { splittable })),
+                cx,
+            );
             editor.set_delegate_open_excerpts(true);
             editor.set_show_vertical_scrollbar(false, cx);
             editor.disable_lsp_data();
@@ -631,10 +801,6 @@ impl SplittableEditor {
             editor
         });
 
-        lhs_editor.update(cx, |editor, cx| {
-            editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx);
-        });
-
         let mut subscriptions = vec![cx.subscribe_in(
             &lhs_editor,
             window,
@@ -665,30 +831,7 @@ impl SplittableEditor {
                         this.expand_excerpts(rhs_anchors.into_iter(), *lines, *direction, cx);
                     }
                 }
-                EditorEvent::StageOrUnstageRequested { stage, hunks } => {
-                    if this.lhs.is_some() {
-                        let translated = translate_lhs_hunks_to_rhs(hunks, this, cx);
-                        if !translated.is_empty() {
-                            let stage = *stage;
-                            this.rhs_editor.update(cx, |editor, cx| {
-                                let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id);
-                                for (buffer_id, hunks) in &chunk_by {
-                                    editor.do_stage_or_unstage(stage, buffer_id, hunks, cx);
-                                }
-                            });
-                        }
-                    }
-                }
-                EditorEvent::RestoreRequested { hunks } => {
-                    if this.lhs.is_some() {
-                        let translated = translate_lhs_hunks_to_rhs(hunks, this, cx);
-                        if !translated.is_empty() {
-                            this.rhs_editor.update(cx, |editor, cx| {
-                                editor.restore_diff_hunks(translated, cx);
-                            });
-                        }
-                    }
-                }
+
                 EditorEvent::OpenExcerptsRequested {
                     selections_by_buffer,
                     split,
@@ -2261,6 +2404,15 @@ mod tests {
             cx.update_global::(|store, cx| {
                 store.update_user_settings(cx, |settings| {
                     settings.editor.diff_view_style = Some(style);
+                    // Pin the geometry-affecting settings these tests' expected
+                    // soft-wrap points were computed against, so they are
+                    // independent of this fork's default settings.
+                    settings
+                        .editor
+                        .gutter
+                        .get_or_insert_default()
+                        .min_line_number_digits = Some(4);
+                    settings.editor.scrollbar.get_or_insert_default().size = Some(15.0);
                 });
             });
             theme_settings::init(theme::LoadThemes::JustBase, cx);
diff --git a/crates/encoding_selector/src/active_buffer_encoding.rs b/crates/encoding_selector/src/active_buffer_encoding.rs
index 6d782343142547..280886dfdf7779 100644
--- a/crates/encoding_selector/src/active_buffer_encoding.rs
+++ b/crates/encoding_selector/src/active_buffer_encoding.rs
@@ -88,6 +88,7 @@ impl Render for ActiveBufferEncoding {
         div().child(
             Button::new("change-encoding", text)
                 .label_size(LabelSize::Small)
+                .tab_index(0isize)
                 .on_click(cx.listener(move |this, _, window, cx| {
                     if disabled {
                         return;
diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml
index 59fc9c3c7ac18d..55af1f4007a473 100644
--- a/crates/extension/Cargo.toml
+++ b/crates/extension/Cargo.toml
@@ -26,6 +26,7 @@ language.workspace = true
 log.workspace = true
 lsp.workspace = true
 parking_lot.workspace = true
+path.workspace = true
 proto.workspace = true
 semver.workspace = true
 serde.workspace = true
diff --git a/crates/extension/src/extension.rs b/crates/extension/src/extension.rs
index 2ec8c8ea5f4032..850da7a69e8bf4 100644
--- a/crates/extension/src/extension.rs
+++ b/crates/extension/src/extension.rs
@@ -56,7 +56,7 @@ pub trait Extension: Send + Sync + 'static {
 
     /// Returns a path relative to this extension's working directory.
     fn path_from_extension(&self, path: &Path) -> PathBuf {
-        util::normalize_path(&self.work_dir().join(path))
+        path::normalize_path(&self.work_dir().join(path))
     }
 
     async fn language_server_command(
diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs
index 6229da6b8dc231..03583f86a248c0 100644
--- a/crates/extension/src/extension_builder.rs
+++ b/crates/extension/src/extension_builder.rs
@@ -11,6 +11,7 @@ use futures::{
 use heck::ToSnakeCase;
 use http_client::{self, AsyncBody, HttpClient};
 use language::LanguageConfig;
+use path::PathExt;
 use semver::Version;
 use serde::Deserialize;
 use std::{
@@ -20,7 +21,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use util::{ResultExt, command::Stdio, rel_path::PathExt};
+use util::{ResultExt, command::Stdio};
 use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _};
 use wasmparser::Parser;
 
diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs
index 6e9207a2e9fbf0..5ec589d718376c 100644
--- a/crates/extension/src/extension_manifest.rs
+++ b/crates/extension/src/extension_manifest.rs
@@ -1,3 +1,4 @@
+use std::borrow::Cow;
 use std::ffi::OsStr;
 use std::fmt;
 use std::path::{Path, PathBuf};
@@ -11,7 +12,8 @@ use language::LanguageName;
 use lsp::LanguageServerName;
 use semver::Version;
 use serde::{Deserialize, Serialize};
-use util::rel_path::{PathExt, RelPathBuf};
+use util::paths::PathStyle;
+use util::rel_path::{RelPath, RelPathBuf};
 
 use crate::ExtensionCapability;
 
@@ -211,9 +213,12 @@ pub fn build_debug_adapter_schema_path(
 ) -> anyhow::Result {
     match &meta.schema_path {
         Some(path) => Ok(path.clone()),
-        None => Path::new("debug_adapter_schemas")
-            .join(Path::new(adapter_name.as_ref()).with_extension("json"))
-            .to_rel_path_buf(),
+        None => RelPath::new(
+            &Path::new("debug_adapter_schemas")
+                .join(Path::new(adapter_name.as_ref()).with_extension("json")),
+            PathStyle::local(),
+        )
+        .map(Cow::into_owned),
     }
 }
 
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 7c77dc79baf7ab..0f8d2c52b2cf81 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,
+    /// Whether to indent with hard tabs (true) or spaces (false).
+    pub hard_tabs: bool,
     /// The preferred line length (column at which to wrap).
     pub preferred_line_length: u32,
 }
diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml
index 623187faaa3fec..0927c246683863 100644
--- a/crates/extension_cli/Cargo.toml
+++ b/crates/extension_cli/Cargo.toml
@@ -34,5 +34,5 @@ theme_settings.workspace = true
 thiserror.workspace = true
 tokio = { workspace = true, features = ["full"] }
 toml.workspace = true
-tree-sitter.workspace = true
+tree-sitter = { workspace = true, features = ["wasm"] }
 wasmtime.workspace = true
diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs
index 676577cbd0cbe2..9ebdef8cf29073 100644
--- a/crates/extension_host/src/extension_host.rs
+++ b/crates/extension_host/src/extension_host.rs
@@ -57,7 +57,7 @@ use std::{
 };
 use task::TaskTemplates;
 use url::Url;
-use util::{ResultExt, paths::RemotePathBuf, rel_path::PathExt};
+use util::{PathExt, ResultExt, paths::RemotePathBuf};
 use wasm_host::{
     WasmExtension, WasmHost,
     wit::{is_supported_wasm_api_version, wasm_api_version_range},
diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs
index 59b00f936f922b..aae9dbb942d8ad 100644
--- a/crates/extension_host/src/extension_store_test.rs
+++ b/crates/extension_host/src/extension_store_test.rs
@@ -769,7 +769,11 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
     theme_extension::init(proxy.clone(), theme_registry.clone(), cx.executor());
     let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
     language_extension::init(
-        LspAccess::ViaLspStore(project.update(cx, |project, _| project.lsp_store())),
+        LspAccess::ViaLspStore(
+            project
+                .update(cx, |project, _| project.lsp_store())
+                .downgrade(),
+        ),
         proxy.clone(),
         language_registry.clone(),
     );
diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs
index 288b31b2202c46..83ba2ad3dfd54d 100644
--- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs
+++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs
@@ -432,7 +432,7 @@ impl ExtensionImports for WasmState {
         self.on_main_thread(|cx| {
             async move {
                 let path = location.as_ref().and_then(|location| {
-                    RelPath::new(Path::new(&location.path), PathStyle::Posix).ok()
+                    RelPath::new(Path::new(&location.path), PathStyle::Unix).ok()
                 });
                 let location = path
                     .as_ref()
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 15e5ff94062094..29971f00da5cee 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
@@ -595,7 +595,7 @@ impl HostWorktree for WasmState {
     ) -> wasmtime::Result> {
         let delegate = self.table.get(&delegate)?;
         Ok(delegate
-            .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?)
+            .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Unix)?)
             .await
             .map_err(|error| error.to_string()))
     }
@@ -948,7 +948,7 @@ impl ExtensionImports for WasmState {
         self.on_main_thread(|cx| {
             async move {
                 let path = location.as_ref().and_then(|location| {
-                    RelPath::new(Path::new(&location.path), PathStyle::Posix).ok()
+                    RelPath::new(Path::new(&location.path), PathStyle::Unix).ok()
                 });
                 let location = path
                     .as_ref()
@@ -968,6 +968,7 @@ impl ExtensionImports for WasmState {
                         );
                         Ok(serde_json::to_string(&settings::LanguageSettings {
                             tab_size: settings.tab_size,
+                            hard_tabs: settings.hard_tabs,
                             preferred_line_length: settings.preferred_line_length,
                         })?)
                     }
diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs
index 2552eacf102455..f9db079a4917e4 100644
--- a/crates/feature_flags/src/flags.rs
+++ b/crates/feature_flags/src/flags.rs
@@ -111,25 +111,10 @@ impl FeatureFlag for AgentThreadWorktreeLabelFlag {
 }
 register_feature_flag!(AgentThreadWorktreeLabelFlag);
 
-/// Moves LLM provider and MCP server configuration out of the dedicated agent
-/// panel page and into the settings UI. When enabled, the agent panel no longer
-/// shows its configuration overlay and the settings UI exposes the "LLM
-/// Providers" and "MCP Servers" sub-pages instead.
-pub struct AgentSettingsUiFeatureFlag;
-
-impl FeatureFlag for AgentSettingsUiFeatureFlag {
-    const NAME: &'static str = "agent-settings-ui";
-    type Value = PresenceFlag;
-}
-register_feature_flag!(AgentSettingsUiFeatureFlag);
-
-/// Wraps agent-run terminal commands in an OS-level sandbox where supported
-/// (currently macOS Seatbelt only). When off, terminal commands run with the
-/// agent's full ambient permissions, as they always have.
-pub struct SandboxingFeatureFlag;
+pub struct AutoWatchFeatureFlag;
 
-impl FeatureFlag for SandboxingFeatureFlag {
-    const NAME: &'static str = "sandboxing";
+impl FeatureFlag for AutoWatchFeatureFlag {
+    const NAME: &'static str = "auto-watch-screens";
     type Value = PresenceFlag;
 }
-register_feature_flag!(SandboxingFeatureFlag);
+register_feature_flag!(AutoWatchFeatureFlag);
diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs
index 8da243f71a1305..49ab000d9054b8 100644
--- a/crates/file_finder/src/file_finder.rs
+++ b/crates/file_finder/src/file_finder.rs
@@ -1,5 +1,7 @@
 #[cfg(test)]
 mod file_finder_tests;
+#[cfg(test)]
+mod multi_select_tests;
 
 use futures::future::join_all;
 pub use open_path_prompt::OpenPathDelegate;
@@ -12,9 +14,9 @@ use file_icons::FileIcons;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use fuzzy_nucleo::{PathMatch, PathMatchCandidate};
 use gpui::{
-    Action, AnyElement, App, Context, DismissEvent, Empty, Entity, EventEmitter, FocusHandle,
-    Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task,
-    TaskExt, WeakEntity, Window, actions, rems,
+    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, TaskExt,
+    WeakEntity, Window, actions, rems,
 };
 use language::{BufferSnapshot, Point};
 use open_path_prompt::{
@@ -38,7 +40,7 @@ use std::{
     },
     time::Duration,
 };
-use ui::{HighlightedLabel, Indicator, ListItem, ListItemSpacing, Tooltip, prelude::*};
+use ui::{Checkbox, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
 use util::{
     ResultExt, maybe,
     paths::{PathStyle, PathWithPosition},
@@ -85,7 +87,14 @@ impl FileFinder {
         workspace.register_action(
             |workspace, action: &workspace::ToggleFileFinder, window, cx| {
                 let Some(file_finder) = workspace.active_modal::(cx) else {
-                    Self::open(workspace, action.separate_history, window, cx).detach();
+                    Self::open(
+                        workspace,
+                        action.separate_history,
+                        action.include_ignored,
+                        window,
+                        cx,
+                    )
+                    .detach();
                     return;
                 };
 
@@ -102,6 +111,7 @@ impl FileFinder {
     fn open(
         workspace: &mut Workspace,
         separate_history: bool,
+        include_ignored: Option,
         window: &mut Window,
         cx: &mut Context,
     ) -> Task<()> {
@@ -154,6 +164,7 @@ impl FileFinder {
                             currently_opened_path,
                             history_items.collect(),
                             separate_history,
+                            include_ignored,
                             window,
                             cx,
                         );
@@ -276,6 +287,10 @@ impl FileFinder {
     ) {
         self.picker.update(cx, |picker, cx| {
             let delegate = &mut picker.delegate;
+            if !delegate.selected_matches.is_empty() {
+                delegate.open_selected_in_one_split(split_direction, window, cx);
+                return;
+            }
             if let Some(workspace) = delegate.workspace.upgrade()
                 && let Some(m) = delegate.matches.get(delegate.selected_index())
             {
@@ -361,6 +376,7 @@ pub struct FileFinderDelegate {
     latest_search_query: Option,
     currently_opened_path: Option,
     matches: Matches,
+    selected_matches: Vec,
     selected_index: usize,
     has_changed_selected_index: bool,
     cancel_flag: Arc,
@@ -459,6 +475,45 @@ impl Match {
     }
 }
 
+/// Wrapper with Eq comparing the worktree-qualified path of file matches
+#[derive(Clone)]
+struct SelectedMatch(pub Match);
+
+impl SelectedMatch {
+    fn new(m: Match) -> Option {
+        match m {
+            Match::History { .. } | Match::Search(_) => Some(Self(m)),
+            Match::Channel { .. } | Match::CreateNew(_) => None,
+        }
+    }
+}
+
+impl PartialEq for SelectedMatch {
+    fn eq(&self, other: &Self) -> bool {
+        *self == other.0
+    }
+}
+
+impl Eq for SelectedMatch {}
+
+impl PartialEq for SelectedMatch {
+    fn eq(&self, other: &Match) -> bool {
+        match (&self.0, other) {
+            (Match::History { path: a, .. }, Match::History { path: b, .. }) => {
+                a.project == b.project
+            }
+            (Match::Search(a), Match::Search(b)) => {
+                a.0.worktree_id == b.0.worktree_id && a.0.path == b.0.path
+            }
+            (Match::History { path: h, .. }, Match::Search(s))
+            | (Match::Search(s), Match::History { path: h, .. }) => {
+                h.project.worktree_id.to_usize() == s.0.worktree_id && h.project.path == s.0.path
+            }
+            _ => false,
+        }
+    }
+}
+
 impl Matches {
     fn len(&self) -> usize {
         self.matches.len()
@@ -907,6 +962,7 @@ impl FileFinderDelegate {
         currently_opened_path: Option,
         history_items: Vec,
         separate_history: bool,
+        include_ignored: Option,
         window: &mut Window,
         cx: &mut Context,
     ) -> Self {
@@ -927,6 +983,7 @@ impl FileFinderDelegate {
             latest_search_query: None,
             currently_opened_path,
             matches: Matches::default(),
+            selected_matches: Vec::new(),
             has_changed_selected_index: false,
             selected_index: 0,
             cancel_flag: Arc::new(AtomicBool::new(false)),
@@ -935,7 +992,7 @@ impl FileFinderDelegate {
             separate_history,
             first_update: true,
             focus_handle: cx.focus_handle(),
-            include_ignored: FileFinderSettings::get_global(cx).include_ignored,
+            include_ignored: include_ignored.or(FileFinderSettings::get_global(cx).include_ignored),
             include_ignored_refresh: Task::ready(()),
         }
     }
@@ -958,6 +1015,22 @@ impl FileFinderDelegate {
         .detach();
     }
 
+    fn prepend_selected_matches(&mut self) {
+        if self.selected_matches.is_empty() {
+            return;
+        }
+        self.matches
+            .matches
+            .retain(|m| !self.selected_matches.iter().any(|selected| selected == m));
+        let mut new: Vec = self
+            .selected_matches
+            .iter()
+            .map(|selected| selected.0.clone())
+            .collect();
+        new.append(&mut self.matches.matches);
+        self.matches.matches = new;
+    }
+
     fn spawn_search(
         &mut self,
         query: FileSearchQuery,
@@ -1160,14 +1233,20 @@ impl FileFinderDelegate {
                 }
             }
 
-            self.selected_index = selected_match.map_or_else(
-                || self.calculate_selected_index(cx),
-                |m| {
-                    self.matches
-                        .position(&m, self.currently_opened_path.as_ref())
-                        .unwrap_or(0)
-                },
-            );
+            self.prepend_selected_matches();
+
+            self.selected_index = if !self.selected_matches.is_empty() {
+                0
+            } else {
+                selected_match.map_or_else(
+                    || self.calculate_selected_index(cx),
+                    |m| {
+                        self.matches
+                            .position(&m, self.currently_opened_path.as_ref())
+                            .unwrap_or(0)
+                    },
+                )
+            };
 
             self.latest_search_query = Some(query);
             self.latest_search_did_cancel = did_cancel;
@@ -1203,7 +1282,11 @@ impl FileFinderDelegate {
                         let full_path = if should_hide_root_in_entry_path(&worktree_store, cx) {
                             entry_path.project.path.clone()
                         } else {
-                            worktree.read(cx).root_name().join(&entry_path.project.path)
+                            worktree
+                                .read(cx)
+                                .root_name()
+                                .join(&entry_path.project.path)
+                                .into()
                         };
                         let mut components = full_path.components();
                         let filename = components.next_back().unwrap_or("");
@@ -1458,6 +1541,21 @@ impl FileFinderDelegate {
         let Some(m) = self.matches.get(self.selected_index()).cloned() else {
             return;
         };
+        let allow_preview = PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
+        self.open_match(m, secondary, dismiss_after_open, allow_preview, window, cx);
+    }
+
+    /// `allow_preview` is forced off for batch opens so every file gets its
+    /// own tab instead of consecutive opens reusing one preview tab.
+    fn open_match(
+        &mut self,
+        m: Match,
+        secondary: bool,
+        dismiss_after_open: bool,
+        allow_preview: bool,
+        window: &mut Window,
+        cx: &mut Context>,
+    ) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
@@ -1480,8 +1578,6 @@ impl FileFinderDelegate {
                                  project_path,
                                  window: &mut Window,
                                  cx: &mut Context| {
-                let allow_preview =
-                    PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
                 if secondary {
                     workspace.split_path_preview(project_path, allow_preview, None, window, cx)
                 } else {
@@ -1596,6 +1692,59 @@ impl FileFinderDelegate {
     ) {
         self.open_selected_file(false, false, window, cx);
     }
+
+    /// Opens every multi-selected file as a tab in a single new split, then
+    /// dismisses the finder.
+    fn open_selected_in_one_split(
+        &mut self,
+        split_direction: SplitDirection,
+        window: &mut Window,
+        cx: &mut Context>,
+    ) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+        let selected = std::mem::take(&mut self.selected_matches);
+        let paths: Vec = selected
+            .iter()
+            .filter_map(|selected| match &selected.0 {
+                Match::History { path, .. } => Some(ProjectPath {
+                    worktree_id: path.project.worktree_id,
+                    path: Arc::clone(&path.project.path),
+                }),
+                Match::Search(m) => Some(project_path_for_search_match(&self.project, &m.0, cx)),
+                Match::Channel { .. } | Match::CreateNew(_) => None,
+            })
+            .collect();
+        if paths.is_empty() {
+            return;
+        }
+        workspace.update(cx, |workspace, cx| {
+            let new_pane =
+                workspace.split_pane(workspace.active_pane().clone(), split_direction, window, cx);
+            let count = paths.len();
+            for (i, path) in paths.into_iter().enumerate() {
+                let focus_item = i + 1 == count;
+                workspace
+                    .open_path_preview(
+                        path,
+                        Some(new_pane.downgrade()),
+                        focus_item,
+                        false,
+                        true,
+                        window,
+                        cx,
+                    )
+                    .detach_and_log_err(cx);
+            }
+        });
+        // Deferred because this runs from a `FileFinder` action handler, so
+        // the entity is already being updated.
+        let finder = self.file_finder.clone();
+        cx.defer(move |cx| {
+            finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err();
+        });
+    }
 }
 
 fn full_path_budget(
@@ -1633,12 +1782,9 @@ impl PickerDelegate for FileFinderDelegate {
             "Include Ignored Files"
         };
 
-        let filter_button = IconButton::new("filter-ignored", IconName::Sliders)
+        let filter_button = IconButton::new("filter-ignored", IconName::FileIgnored)
             .icon_size(IconSize::Small)
             .toggle_state(including_ignored)
-            .when(self.include_ignored.is_some(), |this| {
-                this.indicator(Indicator::dot().color(Color::Info))
-            })
             .tooltip(move |_window, cx| {
                 Tooltip::for_action_in(tooltip_label, &ToggleIncludeIgnored, &focus_handle, cx)
             })
@@ -1709,7 +1855,7 @@ impl PickerDelegate for FileFinderDelegate {
                     .all(|worktree| {
                         worktree
                             .read(cx)
-                            .entry_for_path(RelPath::unix(prefix.split_at(1).0).unwrap())
+                            .entry_for_path(RelPath::from_unix_str(prefix.split_at(1).0).unwrap())
                             .is_none_or(|entry| !entry.is_dir())
                     })
                 {
@@ -1756,6 +1902,7 @@ impl PickerDelegate for FileFinderDelegate {
                 self.first_update = false;
                 self.selected_index = 0;
             }
+            self.prepend_selected_matches();
             cx.notify();
             self.search_in_flight
                 .store(false, atomic::Ordering::Release);
@@ -1806,6 +1953,64 @@ impl PickerDelegate for FileFinderDelegate {
         self.open_selected_file(secondary, true, window, cx);
     }
 
+    fn supports_multi_select(&self) -> bool {
+        true
+    }
+
+    fn is_item_selected(&self, ix: usize) -> bool {
+        let Some(m) = self.matches.get(ix) else {
+            return false;
+        };
+        self.selected_matches.iter().any(|selected| selected == m)
+    }
+
+    fn toggle_item_selected(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        cx: &mut Context>,
+    ) {
+        let Some(m) = self.matches.get(ix).cloned() else {
+            return;
+        };
+        // `new` rejects channels and the `create-new` placeholder.
+        let Some(selected) = SelectedMatch::new(m) else {
+            return;
+        };
+        if let Some(position) = self
+            .selected_matches
+            .iter()
+            .position(|existing| *existing == selected)
+        {
+            self.selected_matches.remove(position);
+        } else {
+            self.selected_matches.push(selected);
+        }
+        cx.notify();
+    }
+
+    fn selected_item_count(&self) -> usize {
+        self.selected_matches.len()
+    }
+
+    fn clear_selection(&mut self, _cx: &mut Context>) {
+        self.selected_matches.clear();
+    }
+
+    fn confirm_multi(
+        &mut self,
+        secondary: bool,
+        window: &mut Window,
+        cx: &mut Context>,
+    ) {
+        let selected = std::mem::take(&mut self.selected_matches);
+        let count = selected.len();
+        for (i, selected_match) in selected.into_iter().enumerate() {
+            let is_last = i + 1 == count;
+            self.open_match(selected_match.0, secondary, is_last, false, window, cx);
+        }
+    }
+
     fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) {
         self.file_finder
             .update(cx, |_, cx| cx.emit(DismissEvent))
@@ -1840,31 +2045,59 @@ impl PickerDelegate for FileFinderDelegate {
         window: &mut Window,
         cx: &mut Context>,
     ) -> Option {
-        let settings = FileFinderSettings::get_global(cx);
+        self.render_match_impl(ix, selected, None, window, cx)
+    }
 
-        let path_match = self.matches.get(ix)?;
+    fn render_match_with_checkbox(
+        &self,
+        ix: usize,
+        selected: bool,
+        checkbox: AnyElement,
+        window: &mut Window,
+        cx: &mut Context>,
+    ) -> Option {
+        self.render_match_impl(ix, selected, Some(checkbox), window, cx)
+    }
 
-        let end_icon = match path_match {
-            Match::History { .. } => Icon::new(IconName::HistoryRerun)
-                .color(Color::Muted)
-                .size(IconSize::Small)
-                .into_any_element(),
-            Match::Search(_) => v_flex()
-                .flex_none()
-                .size(IconSize::Small.rems())
-                .into_any_element(),
-            Match::Channel { .. } => v_flex()
-                .flex_none()
-                .size(IconSize::Small.rems())
-                .into_any_element(),
-            Match::CreateNew(_) => Empty.into_any_element(),
+    fn actions_menu(
+        &self,
+        _window: &mut Window,
+        _cx: &mut Context>,
+    ) -> Vec {
+        let open_label: SharedString = if self.selected_matches.len() > 1 {
+            "Open multiple".into()
+        } else {
+            "Open File".into()
         };
+        vec![
+            picker::PickerAction::header("Split…"),
+            picker::PickerAction::button("Left", pane::SplitLeft::default().boxed_clone()),
+            picker::PickerAction::button("Right", pane::SplitRight::default().boxed_clone()),
+            picker::PickerAction::button("Up", pane::SplitUp::default().boxed_clone()),
+            picker::PickerAction::button("Down", pane::SplitDown::default().boxed_clone()),
+            picker::PickerAction::separator(),
+            picker::PickerAction::button(open_label, menu::Confirm.boxed_clone()),
+        ]
+    }
+}
 
-        let is_create_new = matches!(path_match, Match::CreateNew(_));
+impl FileFinderDelegate {
+    fn render_match_impl(
+        &self,
+        ix: usize,
+        selected: bool,
+        checkbox: Option,
+        window: &mut Window,
+        cx: &mut Context>,
+    ) -> Option {
+        let settings = FileFinderSettings::get_global(cx);
+
+        let path_match = self.matches.get(ix)?;
 
         let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
 
-        let file_icon = match path_match {
+        let start_icon = match path_match {
+            Match::CreateNew(_) => Some(Icon::new(IconName::Plus).size(IconSize::Small)),
             Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
             _ => maybe!({
                 if !settings.file_icons {
@@ -1877,18 +2110,50 @@ impl PickerDelegate for FileFinderDelegate {
             }),
         };
 
+        let checkbox = checkbox.map(|checkbox| {
+            if matches!(path_match, Match::CreateNew(_) | Match::Channel { .. }) {
+                div()
+                    .flex_none()
+                    .size(Checkbox::container_size())
+                    .into_any_element()
+            } else {
+                checkbox
+            }
+        });
+
+        let start_slot: Option = match (checkbox, start_icon) {
+            (Some(checkbox), icon) => Some(
+                h_flex()
+                    .gap_1p5()
+                    .child(checkbox)
+                    .children(icon)
+                    .into_any_element(),
+            ),
+            (None, icon) => icon.map(IntoElement::into_any_element),
+        };
+
+        let end_slot: Option = match path_match {
+            Match::History { .. } => Some(
+                Icon::new(IconName::HistoryRerun)
+                    .color(Color::Muted)
+                    .size(IconSize::Small)
+                    .into_any_element(),
+            ),
+            Match::Search(_) | Match::Channel { .. } => Some(
+                div()
+                    .flex_none()
+                    .size(IconSize::Small.rems())
+                    .into_any_element(),
+            ),
+            Match::CreateNew(_) => None,
+        };
+
         Some(
             ListItem::new(ix)
                 .spacing(ListItemSpacing::Sparse)
                 .inset(true)
                 .toggle_state(selected)
-                .map(|this| {
-                    if is_create_new {
-                        this.start_slot(Icon::new(IconName::Plus).size(IconSize::Small))
-                    } else {
-                        this.start_slot::(file_icon)
-                    }
-                })
+                .start_slot::(start_slot)
                 .child(
                     h_flex()
                         .w_full()
@@ -1897,25 +2162,9 @@ impl PickerDelegate for FileFinderDelegate {
                         .child(file_name_label.truncate_middle())
                         .child(full_path_label.truncate_start()),
                 )
-                .end_slot::(end_icon),
+                .end_slot::(end_slot),
         )
     }
-
-    fn actions_menu(
-        &self,
-        _window: &mut Window,
-        _cx: &mut Context>,
-    ) -> Vec {
-        vec![
-            picker::PickerAction::header("Split…"),
-            picker::PickerAction::button("Left", pane::SplitLeft::default().boxed_clone()),
-            picker::PickerAction::button("Right", pane::SplitRight::default().boxed_clone()),
-            picker::PickerAction::button("Up", pane::SplitUp::default().boxed_clone()),
-            picker::PickerAction::button("Down", pane::SplitDown::default().boxed_clone()),
-            picker::PickerAction::separator(),
-            picker::PickerAction::button("Open File", menu::Confirm.boxed_clone()),
-        ]
-    }
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -1952,7 +2201,7 @@ impl<'a> PathComponentSlice<'a> {
 
     fn elision_range(&self, budget: usize, matches: &[usize]) -> Option> {
         let eligible_range = {
-            assert!(matches.windows(2).all(|w| w[0] <= w[1]));
+            assert!(matches.is_sorted());
             let mut matches = matches.iter().copied().peekable();
             let mut longest: Option> = None;
             let mut cur = 0..0;
diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs
index f3d63a09ee6176..ec49c8a373e5a9 100644
--- a/crates/file_finder/src/file_finder_tests.rs
+++ b/crates/file_finder/src/file_finder_tests.rs
@@ -1094,6 +1094,48 @@ async fn test_ignored_root_with_file_inclusions_repro(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_toggle_action_include_ignored_param(cx: &mut TestAppContext) {
+    let app_state = init_test(cx);
+    let project = Project::test(app_state.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());
+
+    let cases = [
+        (None, Some(true), Some(true)),
+        (None, Some(false), Some(false)),
+        (None, None, None),
+        (Some(true), Some(false), Some(false)),
+        (Some(false), Some(true), Some(true)),
+        (Some(true), None, Some(true)),
+    ];
+    for (setting, action_param, expected) in cases {
+        cx.update(|_, cx| {
+            let settings = *FileFinderSettings::get_global(cx);
+            FileFinderSettings::override_global(
+                FileFinderSettings {
+                    include_ignored: setting,
+                    ..settings
+                },
+                cx,
+            );
+        });
+        cx.dispatch_action(ToggleFileFinder {
+            separate_history: false,
+            include_ignored: action_param,
+        });
+        let picker = active_file_picker(&workspace, cx);
+        picker.update(cx, |picker, _| {
+            assert_eq!(
+                picker.delegate.include_ignored, expected,
+                "setting: {setting:?}, action param: {action_param:?}"
+            );
+        });
+        cx.dispatch_action(menu::Cancel);
+    }
+}
+
 #[gpui::test]
 async fn test_ignored_root(cx: &mut TestAppContext) {
     let app_state = init_test(cx);
@@ -4477,7 +4519,7 @@ async fn open_queried_buffer(
     history_items
 }
 
-fn init_test(cx: &mut TestAppContext) -> Arc {
+pub(crate) fn init_test(cx: &mut TestAppContext) -> Arc {
     cx.update(|cx| {
         let state = AppState::test(cx);
         theme_settings::init(theme::LoadThemes::JustBase, cx);
@@ -4507,12 +4549,13 @@ fn build_find_picker(
 }
 
 #[track_caller]
-fn open_file_picker(
+pub(crate) fn open_file_picker(
     workspace: &Entity,
     cx: &mut VisualTestContext,
 ) -> Entity> {
     cx.dispatch_action(ToggleFileFinder {
         separate_history: true,
+        include_ignored: None,
     });
     active_file_picker(workspace, cx)
 }
@@ -4529,7 +4572,7 @@ fn simulate_input(cx: &mut VisualTestContext, input: &str) {
 }
 
 #[track_caller]
-fn active_file_picker(
+pub(crate) fn active_file_picker(
     workspace: &Entity,
     cx: &mut VisualTestContext,
 ) -> Entity> {
@@ -4584,7 +4627,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries
                 if let Some(path_match) = path_match.as_ref() {
                     search_entries
                         .history
-                        .push(path_match.0.path_prefix.join(&path_match.0.path));
+                        .push(path_match.0.path_prefix.join(&path_match.0.path).into());
                 } else {
                     // This occurs when the query is empty and we show history matches
                     // that are outside the project.
@@ -4597,7 +4640,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries
             Match::Search(path_match) => {
                 search_entries
                     .search
-                    .push(path_match.0.path_prefix.join(&path_match.0.path));
+                    .push(path_match.0.path_prefix.join(&path_match.0.path).into());
                 search_entries.search_matches.push(path_match.0.clone());
             }
             Match::CreateNew(_) => {}
diff --git a/crates/file_finder/src/multi_select_tests.rs b/crates/file_finder/src/multi_select_tests.rs
new file mode 100644
index 00000000000000..d7e39ae7548c7e
--- /dev/null
+++ b/crates/file_finder/src/multi_select_tests.rs
@@ -0,0 +1,294 @@
+//! Tests for multi-selecting files in the file finder: toggling items in and
+//! out of the selection, pinning the selection to the top across query
+//! changes, and the various ways of opening the whole selection.
+
+use gpui::{BorrowAppContext, Entity, TestAppContext, VisualTestContext};
+use picker::{MultiSelectNext, Picker, PickerDelegate as _};
+use pretty_assertions::assert_eq;
+use project::Project;
+use serde_json::json;
+use settings::SettingsStore;
+use util::path;
+use workspace::{MultiWorkspace, Workspace, pane};
+
+use crate::file_finder_tests::{self, open_file_picker};
+use crate::{FileFinder, FileFinderDelegate, SEARCH_DEBOUNCE};
+
+struct TestContext {
+    picker: Entity>,
+    workspace: Entity,
+    cx: VisualTestContext,
+}
+
+impl TestContext {
+    /// The test tree is `a.rs`, `b.rs` and `c.rs` in the worktree root.
+    /// Preview tabs from the file finder are enabled to verify that batch
+    /// opens produce real tabs regardless.
+    async fn new(cx: &mut TestAppContext) -> TestContext {
+        let app_state = file_finder_tests::init_test(cx);
+        cx.update(|cx| {
+            cx.update_global::(|store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings
+                        .preview_tabs
+                        .get_or_insert_default()
+                        .enable_preview_from_file_finder = Some(true);
+                });
+            })
+        });
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/root"),
+                json!({
+                    "a.rs": "// a",
+                    "b.rs": "// b",
+                    "c.rs": "// c",
+                }),
+            )
+            .await;
+        let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+        let window =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = window
+            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+            .unwrap();
+        let mut cx = VisualTestContext::from_window(window.into(), cx);
+        let picker = open_file_picker(&workspace, &mut cx);
+        TestContext {
+            picker,
+            workspace,
+            cx,
+        }
+    }
+
+    fn reopen_finder(&mut self) {
+        self.picker = open_file_picker(&self.workspace, &mut self.cx);
+    }
+
+    fn search(&mut self, query: &str) {
+        self.picker.update_in(&mut self.cx, |picker, window, cx| {
+            picker.set_query(query, window, cx)
+        });
+        self.cx.executor().advance_clock(SEARCH_DEBOUNCE);
+        self.cx.run_until_parked();
+    }
+
+    fn select(&mut self, file_name: &str) {
+        let index = self.index_of(file_name);
+        self.picker.update_in(&mut self.cx, |picker, window, cx| {
+            picker.delegate.set_selected_index(index, window, cx);
+        });
+        self.cx.dispatch_action(MultiSelectNext);
+        self.cx.run_until_parked();
+    }
+
+    fn deselect(&mut self, file_name: &str) {
+        let index = self.index_of(file_name);
+        self.picker.update_in(&mut self.cx, |picker, window, cx| {
+            picker.delegate.toggle_item_selected(index, window, cx);
+        });
+        self.cx.run_until_parked();
+    }
+
+    fn confirm(&mut self) {
+        self.cx.dispatch_action(menu::Confirm);
+        self.cx.run_until_parked();
+    }
+
+    fn secondary_confirm(&mut self) {
+        self.cx.dispatch_action(menu::SecondaryConfirm);
+        self.cx.run_until_parked();
+    }
+
+    fn split_right(&mut self) {
+        self.cx.dispatch_action(pane::SplitRight::default());
+        self.cx.run_until_parked();
+    }
+
+    fn index_of(&mut self, file_name: &str) -> usize {
+        self.picker.update(&mut self.cx, |picker, _| {
+            picker
+                .delegate
+                .matches
+                .matches
+                .iter()
+                .position(|m| {
+                    m.relative_path()
+                        .is_some_and(|path| path.file_name() == Some(file_name))
+                })
+                .unwrap_or_else(|| panic!("{file_name} is not in the match list"))
+        })
+    }
+
+    fn match_names(&mut self) -> Vec {
+        self.picker.update(&mut self.cx, |picker, _| {
+            picker
+                .delegate
+                .matches
+                .matches
+                .iter()
+                .filter_map(|m| Some(m.relative_path()?.file_name()?.to_owned()))
+                .collect()
+        })
+    }
+
+    #[track_caller]
+    fn assert_selected(&mut self, expected: &[&str]) {
+        let selected: Vec = self.picker.update(&mut self.cx, |picker, _| {
+            picker
+                .delegate
+                .selected_matches
+                .iter()
+                .filter_map(|selected| Some(selected.0.relative_path()?.file_name()?.to_owned()))
+                .collect()
+        });
+        assert_eq!(selected, expected, "wrong files selected");
+    }
+
+    #[track_caller]
+    fn assert_finder_closed(&mut self) {
+        let finder_open = self.workspace.update(&mut self.cx, |workspace, cx| {
+            workspace.active_modal::(cx).is_some()
+        });
+        assert!(!finder_open, "the finder should have been dismissed");
+    }
+
+    #[track_caller]
+    fn assert_active_pane_items(&mut self, expected: &[&str]) {
+        let mut items = self.pane_item_names(
+            &self
+                .workspace
+                .read_with(&self.cx, |workspace, _| workspace.active_pane().clone()),
+        );
+        let mut expected: Vec = expected.iter().map(|name| name.to_string()).collect();
+        items.sort();
+        expected.sort();
+        assert_eq!(items, expected, "wrong items in the active pane");
+    }
+
+    fn pane_item_names(&mut self, pane: &Entity) -> Vec {
+        pane.read_with(&self.cx, |pane, cx| {
+            pane.items()
+                .filter_map(|item| Some(item.project_path(cx)?.path.file_name()?.to_owned()))
+                .collect()
+        })
+    }
+
+    fn pane_count(&mut self) -> usize {
+        self.workspace
+            .read_with(&self.cx, |workspace, _| workspace.panes().len())
+    }
+}
+
+#[gpui::test]
+async fn open_selection_as_tabs(cx: &mut TestAppContext) {
+    let mut cx = TestContext::new(cx).await;
+
+    cx.search("rs");
+    cx.select("b.rs");
+    cx.select("c.rs");
+    cx.assert_selected(&["b.rs", "c.rs"]);
+
+    cx.confirm();
+    cx.assert_finder_closed();
+    // Both files must open as real tabs even though preview tabs from the
+    // file finder are enabled; a batch open must not reuse one preview tab.
+    cx.assert_active_pane_items(&["b.rs", "c.rs"]);
+}
+
+#[gpui::test]
+async fn tabbing_a_selected_row_deselects_it(cx: &mut TestAppContext) {
+    let mut cx = TestContext::new(cx).await;
+
+    cx.search("rs");
+    cx.select("b.rs");
+    cx.select("c.rs");
+    cx.select("b.rs");
+    cx.assert_selected(&["c.rs"]);
+}
+
+#[gpui::test]
+async fn selection_pins_to_top_across_queries(cx: &mut TestAppContext) {
+    let mut cx = TestContext::new(cx).await;
+
+    cx.search("c");
+    cx.select("c.rs");
+
+    // `c.rs` doesn't match the new query, but stays selected and pinned to
+    // the top of the results.
+    cx.search("b");
+    cx.assert_selected(&["c.rs"]);
+    assert_eq!(cx.match_names(), ["c.rs", "b.rs"]);
+}
+
+#[gpui::test]
+async fn deselecting_survives_queries(cx: &mut TestAppContext) {
+    let mut cx = TestContext::new(cx).await;
+
+    cx.search("c");
+    cx.select("c.rs");
+
+    cx.search("b");
+    cx.deselect("c.rs");
+    cx.assert_selected(&[]);
+
+    // A deselected file must not come back selected or pinned on requery.
+    cx.search("a");
+    cx.assert_selected(&[]);
+    assert_eq!(cx.match_names(), ["a.rs"]);
+}
+
+#[gpui::test]
+async fn create_new_file_row_is_not_selectable(cx: &mut TestAppContext) {
+    let mut cx = TestContext::new(cx).await;
+
+    // A query matching nothing produces only the "create new" row.
+    cx.search("zzz");
+    cx.picker.update_in(&mut cx.cx, |picker, window, cx| {
+        picker.delegate.set_selected_index(0, window, cx);
+    });
+    cx.cx.dispatch_action(MultiSelectNext);
+    cx.cx.run_until_parked();
+    cx.assert_selected(&[]);
+}
+
+#[gpui::test]
+async fn open_selection_in_one_split(cx: &mut TestAppContext) {
+    let mut cx = TestContext::new(cx).await;
+
+    cx.search("rs");
+    cx.select("b.rs");
+    cx.select("c.rs");
+
+    cx.split_right();
+    cx.assert_finder_closed();
+    // One new pane holding both files as tabs, not one pane per file.
+    assert_eq!(cx.pane_count(), 2);
+    cx.assert_active_pane_items(&["b.rs", "c.rs"]);
+}
+
+#[gpui::test]
+async fn secondary_confirm_opens_one_split_per_file(cx: &mut TestAppContext) {
+    let mut cx = TestContext::new(cx).await;
+
+    // Open a file normally first so the workspace has a non-empty pane to
+    // split off from.
+    cx.search("a");
+    cx.confirm();
+    cx.assert_active_pane_items(&["a.rs"]);
+
+    cx.reopen_finder();
+    cx.search("rs");
+    cx.select("b.rs");
+    cx.select("c.rs");
+
+    cx.secondary_confirm();
+    cx.assert_finder_closed();
+    assert_eq!(
+        cx.pane_count(),
+        3,
+        "each selected file opens in its own split"
+    );
+}
diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml
index 5f95536e6c526b..ef0cb4bd8c20db 100644
--- a/crates/fs/Cargo.toml
+++ b/crates/fs/Cargo.toml
@@ -36,19 +36,21 @@ proto.workspace = true
 thiserror.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+slotmap.workspace = true
 smol.workspace = true
 telemetry.workspace = true
 tempfile.workspace = true
 text.workspace = true
 time.workspace = true
 util.workspace = true
+path.workspace = true
 is_executable = "1.0.5"
 notify = "9.0.0-rc.4"
-trash = { git = "https://github.com/zed-industries/trash-rs", rev = "3bf27effd4eb8699f2e484d3326b852fe3e53af7" }
-
+trash = { git = "https://github.com/zed-industries/trash-rs", rev = "47761739192828a66b11a94ba5420b82d63c03c5" }
 [target.'cfg(target_os = "windows")'.dependencies]
 windows.workspace = true
 dunce.workspace = true
+async-std.workspace = true
 
 [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
 ashpd.workspace = true
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 45ee57b35f5ce6..02610f0fdb5243 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -162,28 +162,6 @@ impl FakeGitRepository {
 }
 
 impl GitRepository for FakeGitRepository {
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let fut = self.with_state_async(false, move |state| {
-            state
-                .index_contents
-                .get(&path)
-                .context("not present in index")
-                .cloned()
-        });
-        self.executor.spawn(async move { fut.await.ok() }).boxed()
-    }
-
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let fut = self.with_state_async(false, move |state| {
-            state
-                .head_contents
-                .get(&path)
-                .context("not present in HEAD")
-                .cloned()
-        });
-        self.executor.spawn(async move { fut.await.ok() }).boxed()
-    }
-
     fn load_commit_template(&self) -> BoxFuture<'_, Result>> {
         async { Ok(None) }.boxed()
     }
@@ -264,9 +242,38 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>> {
+        let fut = self.with_state_async(false, move |state| {
+            Ok(revisions
+                .into_iter()
+                .map(|rev| {
+                    let (prefix, path) = rev.split_once(':')?;
+                    let repo_path = RepoPath::new(path).ok()?;
+                    match prefix {
+                        "" => state.index_contents.get(&repo_path).cloned(),
+                        "HEAD" => state.head_contents.get(&repo_path).cloned(),
+                        _ => None,
+                    }
+                })
+                .collect())
+        });
+        self.executor.spawn(fut).boxed()
+    }
+
     fn show(&self, commit: String) -> BoxFuture<'_, Result> {
         self.with_state_async(false, move |state| {
-            let sha = state.refs.get(&commit).cloned().unwrap_or(commit);
+            let sha = match state.refs.get(&commit) {
+                Some(sha) => sha.clone(),
+                // Real git fails to show an unresolvable revision (e.g. HEAD on an
+                // unborn branch), so only fall back to treating the input as a sha.
+                None => {
+                    anyhow::ensure!(
+                        commit.parse::().is_ok(),
+                        "unable to resolve revision: {commit}"
+                    );
+                    commit
+                }
+            };
             Ok(CommitDetails {
                 sha: sha.into(),
                 message: "initial commit".into(),
@@ -1149,6 +1156,7 @@ impl GitRepository for FakeGitRepository {
 
     fn diff_stat(
         &self,
+        diff: git::repository::DiffStatType,
         path_prefixes: &[RepoPath],
     ) -> BoxFuture<'static, Result> {
         fn count_lines(s: &str) -> u32 {
@@ -1196,22 +1204,43 @@ impl GitRepository for FakeGitRepository {
 
         self.with_state_async(false, move |state| {
             let mut entries = Vec::new();
-            let all_paths: HashSet<&RepoPath> = state
-                .head_contents
-                .keys()
-                .chain(
-                    worktree_files
-                        .keys()
-                        .filter(|p| state.index_contents.contains_key(*p)),
-                )
-                .collect();
+            let (old_files, new_files) = match diff {
+                git::repository::DiffStatType::HeadToIndex => {
+                    (&state.head_contents, &state.index_contents)
+                }
+                git::repository::DiffStatType::HeadToWorktree => {
+                    (&state.head_contents, &worktree_files)
+                }
+                git::repository::DiffStatType::IndexToWorktree => {
+                    (&state.index_contents, &worktree_files)
+                }
+            };
+            let all_paths: HashSet<&RepoPath> = match diff {
+                git::repository::DiffStatType::HeadToIndex => state
+                    .head_contents
+                    .keys()
+                    .chain(state.index_contents.keys())
+                    .collect(),
+                git::repository::DiffStatType::HeadToWorktree => state
+                    .head_contents
+                    .keys()
+                    .chain(
+                        worktree_files
+                            .keys()
+                            .filter(|path| state.index_contents.contains_key(*path)),
+                    )
+                    .collect(),
+                git::repository::DiffStatType::IndexToWorktree => {
+                    state.index_contents.keys().collect()
+                }
+            };
             for path in all_paths {
                 if !matches_prefixes(path, &path_prefixes) {
                     continue;
                 }
-                let head = state.head_contents.get(path);
-                let worktree = worktree_files.get(path);
-                match (head, worktree) {
+                let old_file = old_files.get(path);
+                let new_file = new_files.get(path);
+                match (old_file, new_file) {
                     (Some(old), Some(new)) if old != new => {
                         entries.push((
                             path.clone(),
diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs
index 59fe449b185b1c..01a76c04699620 100644
--- a/crates/fs/src/fs.rs
+++ b/crates/fs/src/fs.rs
@@ -3,6 +3,7 @@ pub mod fs_watcher;
 pub use fs_watcher::requires_poll_watcher;
 
 use parking_lot::Mutex;
+use slotmap::{KeyData, SlotMap};
 use std::ffi::OsString;
 use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
 use std::time::Instant;
@@ -61,7 +62,7 @@ use git::{
     status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus},
 };
 #[cfg(feature = "test-support")]
-use util::normalize_path;
+use path::normalize_path;
 
 #[cfg(feature = "test-support")]
 use smol::io::AsyncReadExt;
@@ -119,7 +120,7 @@ pub trait Fs: Send + Sync {
     /// Moves a file or directory to the system trash.
     /// Returns a [`TrashedEntry`] that can be used to keep track of the
     /// location of the trashed item in the system's trash.
-    async fn trash(&self, path: &Path, options: RemoveOptions) -> Result;
+    async fn trash(&self, path: &Path, options: RemoveOptions) -> Result;
 
     /// Removes a file from the filesystem.
     /// There is no expectation that the file will be preserved in the system
@@ -169,10 +170,7 @@ pub trait Fs: Send + Sync {
 
     /// Restores a given `TrashedEntry`, moving it from the system's trash back
     /// to the original path.
-    async fn restore(
-        &self,
-        trashed_entry: TrashedEntry,
-    ) -> std::result::Result;
+    async fn restore(&self, item: TrashId) -> std::result::Result;
 
     #[cfg(feature = "test-support")]
     fn as_fake(&self) -> Arc {
@@ -186,7 +184,7 @@ pub trait Fs: Send + Sync {
 /// Represents a file or directory that has been moved to the system trash,
 /// retaining enough information to restore it to its original location.
 #[derive(Clone, PartialEq, Debug)]
-pub struct TrashedEntry {
+struct TrashedEntry {
     /// Platform-specific identifier for the file/directory in the trash.
     ///
     /// * Freedesktop – Path to the `.trashinfo` file.
@@ -228,6 +226,11 @@ pub enum TrashRestoreError {
     NotFound { path: PathBuf },
     #[error("File or directory ({}) already exists at the restore destination.", path.display())]
     Collision { path: PathBuf },
+    // This should never occur, the only way to get a TrashId is to undo
+    // consumes the Change::Trashed. We worry about remoting duplicate messages
+    // we do not want to crash the app then which is why this error is there.
+    #[error("The item was already restored")]
+    AlreadyRestored,
     #[error("Unknown error ({description})")]
     Unknown { description: String },
 }
@@ -295,6 +298,7 @@ pub struct Metadata {
     pub len: u64,
     pub is_fifo: bool,
     pub is_executable: bool,
+    pub is_writable: bool,
 }
 
 /// Filesystem modification time. The purpose of this newtype is to discourage use of operations
@@ -395,11 +399,24 @@ impl From for proto::Timestamp {
     }
 }
 
+slotmap::new_key_type! { pub struct TrashId; }
+
+impl TrashId {
+    pub fn from_proto(value: u64) -> Self {
+        KeyData::from_ffi(value).into()
+    }
+
+    pub fn to_proto(self) -> u64 {
+        self.0.as_ffi()
+    }
+}
+
 pub struct RealFs {
     bundled_git_binary_path: Option,
     executor: BackgroundExecutor,
     next_job_id: Arc,
     job_event_subscribers: Arc>>,
+    trash: Arc>>,
     is_case_sensitive: AtomicU8,
 }
 
@@ -508,6 +525,7 @@ impl RealFs {
             executor,
             next_job_id: Arc::new(AtomicUsize::new(0)),
             job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
+            trash: Arc::new(Mutex::new(SlotMap::with_key())),
             is_case_sensitive: Default::default(),
         }
     }
@@ -798,7 +816,7 @@ impl Fs for RealFs {
         }
     }
 
-    async fn trash(&self, path: &Path, _options: RemoveOptions) -> Result {
+    async fn trash(&self, path: &Path, _options: RemoveOptions) -> Result {
         // We must make the path absolute or trash will make a weird abomination
         // of the zed working directory (not usually the worktree) and whatever
         // the path variable holds.
@@ -807,17 +825,12 @@ impl Fs for RealFs {
         // its target and leave the link behind.
         let path = std::path::absolute(path).context("Could not make the path absolute")?;
 
-        let (tx, rx) = futures::channel::oneshot::channel();
-        std::thread::Builder::new()
-            .name("trash file or dir".to_string())
-            .spawn(|| tx.send(trash::delete_with_info(path)))
-            .expect("The os can spawn threads");
-
-        Ok(rx
+        let entry = smol::unblock(move || trash::delete_with_info(path))
             .await
-            .context("Tx dropped or fs.restore panicked")?
             .context("Could not trash file or dir")?
-            .into())
+            .into();
+
+        Ok(self.trash.lock().insert(entry))
     }
 
     async fn open_sync(&self, path: &Path) -> Result> {
@@ -1032,6 +1045,7 @@ impl Fs for RealFs {
             is_dir: metadata.file_type().is_dir(),
             is_fifo,
             is_executable,
+            is_writable: !metadata.permissions().readonly(),
         }))
     }
 
@@ -1098,7 +1112,11 @@ impl Fs for RealFs {
                 }
             }
             watcher.add(&target).ok();
-            if let Some(parent) = target.parent() {
+            // Skipped for poll watchers: PollWatcher::watch() recursively scans
+            // at registration, blocking on large virtual filesystem mounts
+            if let Some(parent) = target.parent()
+                && !fs_watcher::requires_poll_watcher(parent)
+            {
                 watcher.add(parent).log_err();
             }
         }
@@ -1282,10 +1300,13 @@ impl Fs for RealFs {
         res
     }
 
-    async fn restore(
-        &self,
-        trashed_entry: TrashedEntry,
-    ) -> std::result::Result {
+    async fn restore(&self, item: TrashId) -> std::result::Result {
+        let trashed_entry = self
+            .trash
+            .lock()
+            .remove(item)
+            .ok_or(TrashRestoreError::AlreadyRestored)?;
+
         let restored_item_path = trashed_entry.original_parent.join(&trashed_entry.name);
 
         let (tx, rx) = futures::channel::oneshot::channel();
@@ -1334,7 +1355,7 @@ struct FakeFsState {
     path_write_counts: std::collections::HashMap,
     moves: std::collections::HashMap,
     job_event_subscribers: Arc>>,
-    trash: Vec<(TrashedEntry, FakeFsEntry)>,
+    trash: Mutex>,
     file_to_create_before_watch_add: Option<(PathBuf, PathBuf)>,
     remove_dir_errors: std::collections::HashMap,
 }
@@ -1654,7 +1675,7 @@ impl FakeFs {
                 path_write_counts: Default::default(),
                 moves: Default::default(),
                 job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
-                trash: Vec::new(),
+                trash: Mutex::new(SlotMap::with_key()),
                 file_to_create_before_watch_add: None,
                 remove_dir_errors: Default::default(),
             })),
@@ -2404,7 +2425,18 @@ impl FakeFs {
         self.state
             .lock()
             .remove_dir_errors
-            .insert(normalize_path(path.as_ref()), message);
+            .insert(Self::remove_dir_error_key(path.as_ref()), message);
+    }
+
+    /// Entry resolution in `try_entry` ignores drive prefixes, so the error
+    /// injection map must too.
+    /// Otherwise, on Windows, a key like `C:\workspace\dir` would never match a
+    /// lookup for `\workspace\dir`.
+    fn remove_dir_error_key(path: &Path) -> PathBuf {
+        normalize_path(path)
+            .components()
+            .skip_while(|component| matches!(component, Component::Prefix(_)))
+            .collect()
     }
 
     pub fn paths(&self, include_dot_git: bool) -> Vec {
@@ -2531,16 +2563,6 @@ impl FakeFs {
         self.executor.simulate_random_delay()
     }
 
-    /// Returns list of all tracked trash entries.
-    pub fn trash_entries(&self) -> Vec {
-        self.state
-            .lock()
-            .trash
-            .iter()
-            .map(|(entry, _)| entry.clone())
-            .collect()
-    }
-
     async fn remove_dir_inner(
         &self,
         path: &Path,
@@ -2549,7 +2571,12 @@ impl FakeFs {
         self.simulate_random_delay().await;
 
         let path = normalize_path(path);
-        if let Some(message) = self.state.lock().remove_dir_errors.get(&path) {
+        if let Some(message) = self
+            .state
+            .lock()
+            .remove_dir_errors
+            .get(&Self::remove_dir_error_key(&path))
+        {
             anyhow::bail!("{message}");
         }
         let parent_path = path.parent().context("cannot remove the root")?;
@@ -2617,6 +2644,20 @@ impl FakeFs {
         state.emit_event([(path, Some(PathEventKind::Removed))]);
         Ok(removed)
     }
+
+    pub fn trashed_paths(&self) -> Vec {
+        self.state
+            .lock()
+            .trash
+            .lock()
+            .values()
+            .map(|(trashed_entry, _fake_entry)| {
+                PathBuf::new()
+                    .join(trashed_entry.original_parent.clone())
+                    .join(trashed_entry.name.clone())
+            })
+            .collect::>()
+    }
 }
 
 #[cfg(feature = "test-support")]
@@ -2933,7 +2974,7 @@ impl Fs for FakeFs {
         self.remove_dir_inner(path, options).await.map(|_| ())
     }
 
-    async fn trash(&self, path: &Path, options: RemoveOptions) -> Result {
+    async fn trash(&self, path: &Path, options: RemoveOptions) -> Result {
         let normalized_path = normalize_path(path);
         let parent_path = normalized_path.parent().context("cannot remove the root")?;
         let base_name = normalized_path.file_name().unwrap();
@@ -2951,9 +2992,14 @@ impl Fs for FakeFs {
                     original_parent: parent_path.to_path_buf(),
                 };
 
-                let mut state = self.state.lock();
-                state.trash.push((trashed_entry.clone(), fake_entry));
-                Ok(trashed_entry)
+                let trash_id = self
+                    .state
+                    .lock()
+                    .trash
+                    .lock()
+                    .insert((trashed_entry, fake_entry));
+
+                Ok(trash_id)
             }
             None => anyhow::bail!("{normalized_path:?} does not exist"),
         }
@@ -3072,6 +3118,7 @@ impl Fs for FakeFs {
                     is_symlink,
                     is_fifo: false,
                     is_executable: false,
+                    is_writable: true,
                 },
                 FakeFsEntry::Dir {
                     inode, mtime, len, ..
@@ -3083,6 +3130,7 @@ impl Fs for FakeFs {
                     is_symlink,
                     is_fifo: false,
                     is_executable: false,
+                    is_writable: true,
                 },
                 FakeFsEntry::Symlink { .. } => unreachable!(),
             }))
@@ -3214,18 +3262,11 @@ impl Fs for FakeFs {
         receiver
     }
 
-    async fn restore(&self, trashed_entry: TrashedEntry) -> Result {
+    async fn restore(&self, trash_id: TrashId) -> Result {
         let mut state = self.state.lock();
 
-        let Some((trashed_entry, fake_entry)) = state
-            .trash
-            .iter()
-            .find(|(entry, _)| *entry == trashed_entry)
-            .cloned()
-        else {
-            return Err(TrashRestoreError::NotFound {
-                path: PathBuf::from(trashed_entry.id),
-            });
+        let Some((trashed_entry, fake_entry)) = state.trash.lock().remove(trash_id) else {
+            return Err(TrashRestoreError::AlreadyRestored);
         };
 
         let path = trashed_entry
@@ -3244,7 +3285,6 @@ impl Fs for FakeFs {
 
         match result {
             Ok(_) => {
-                state.trash.retain(|(entry, _)| *entry != trashed_entry);
                 state.emit_event([(path.clone(), Some(PathEventKind::Created))]);
                 Ok(path)
             }
diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs
index 4fc663809547de..99664ae74802e2 100644
--- a/crates/fs/src/fs_watcher.rs
+++ b/crates/fs/src/fs_watcher.rs
@@ -56,13 +56,22 @@ impl FsWatcher {
             log::trace!("path to watch is already watched: {path:?}");
             return Ok(());
         }
-        if let Some(registration) = register_existing_path(
-            path,
+        match register_existing_path(
+            path.clone(),
             case_insensitive,
             self.tx.clone(),
             self.pending_path_events.clone(),
         )? {
-            self.registrations.lock().insert(key, registration);
+            Some(registration) => {
+                self.registrations.lock().insert(key, registration);
+            }
+            None => {
+                // Registration was skipped (e.g. the native watch-limit cooldown
+                // is active). Retry in the background rather than silently leaving
+                // the path unwatched forever.
+                log::warn!("watch registration for {path:?} was skipped; retrying in background");
+                self.add_pending_path(path);
+            }
         }
         Ok(())
     }
@@ -1245,6 +1254,56 @@ mod tests {
         assert_eq!(backend.unwatch_calls, &[parent.to_path_buf()]);
     }
 
+    #[gpui::test]
+    async fn pending_path_is_registered_once_created(cx: &mut gpui::TestAppContext) {
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        let path = temp_dir.path().join("file.txt");
+
+        let (tx, rx) = async_channel::unbounded();
+        let pending_path_events: Arc>> = Default::default();
+        let watcher = FsWatcher::new(cx.executor(), tx, pending_path_events.clone());
+
+        watcher
+            .add(&path)
+            .expect("add path that does not exist yet");
+        assert!(
+            watcher
+                .pending_registrations
+                .lock()
+                .contains_key(path.as_path())
+        );
+        assert!(watcher.registrations.lock().is_empty());
+
+        std::fs::write(&path, b"contents").expect("create path");
+
+        // poll_path_until_created stats the path on smol's blocking pool, which
+        // the deterministic executor cannot drive; park until the poll task
+        // signals the event channel.
+        cx.executor().allow_parking();
+        cx.executor().advance_clock(poll_interval());
+        rx.recv().await.expect("receive watcher event");
+
+        assert!(
+            !watcher
+                .pending_registrations
+                .lock()
+                .contains_key(path.as_path())
+        );
+        let case_insensitive = case_insensitive_path(&path);
+        let key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive);
+        assert!(watcher.registrations.lock().contains_key(&key));
+
+        // poll_path_until_created also enqueues a Rescan for the same path, but
+        // enqueue_path_events -> util::extend_sorted dedups by path, so only Created survives.
+        assert_eq!(
+            pending_path_events.lock().clone(),
+            vec![PathEvent {
+                path: path.clone(),
+                kind: Some(PathEventKind::Created),
+            }]
+        );
+    }
+
     #[test]
     fn native_watch_limit_cools_down_subsequent_native_registrations() {
         let native_backend = Arc::new(Mutex::new(FakeWatchBackend {
diff --git a/crates/fs/tests/integration/fs_tests.rs b/crates/fs/tests/integration/fs_tests.rs
index 6f690be8967f7a..c644229a0da954 100644
--- a/crates/fs/tests/integration/fs_tests.rs
+++ b/crates/fs/tests/integration/fs_tests.rs
@@ -2,7 +2,6 @@ mod fake_git_repo_tests;
 
 use std::{
     collections::BTreeSet,
-    ffi::OsString,
     io::Write,
     path::{Path, PathBuf},
     pin::Pin,
@@ -648,15 +647,11 @@ async fn test_fake_fs_trash(executor: BackgroundExecutor) {
     .await;
 
     // Trashing a file.
-    let root_path = PathBuf::from(path!("/root"));
     let path = path!("/root/file_a.txt").as_ref();
-    let trashed_entry = fs
-        .trash(path, Default::default())
+    fs.trash(path, Default::default())
         .await
         .expect("should be able to trash {path:?}");
 
-    assert_eq!(trashed_entry.name, "file_a.txt");
-    assert_eq!(trashed_entry.original_parent, root_path);
     assert_eq!(
         fs.files(),
         vec![
@@ -666,32 +661,19 @@ async fn test_fake_fs_trash(executor: BackgroundExecutor) {
         ]
     );
 
-    let trash_entries = fs.trash_entries();
-    assert_eq!(trash_entries.len(), 1);
-    assert_eq!(trash_entries[0].name, "file_a.txt");
-    assert_eq!(trash_entries[0].original_parent, root_path);
-
     // Trashing a directory.
     let path = path!("/root/src").as_ref();
-    let trashed_entry = fs
-        .trash(
-            path,
-            RemoveOptions {
-                recursive: true,
-                ..Default::default()
-            },
-        )
-        .await
-        .expect("should be able to trash {path:?}");
+    fs.trash(
+        path,
+        RemoveOptions {
+            recursive: true,
+            ..Default::default()
+        },
+    )
+    .await
+    .expect("should be able to trash {path:?}");
 
-    assert_eq!(trashed_entry.name, "src");
-    assert_eq!(trashed_entry.original_parent, root_path);
     assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_b.txt"))]);
-
-    let trash_entries = fs.trash_entries();
-    assert_eq!(trash_entries.len(), 2);
-    assert_eq!(trash_entries[1].name, "src");
-    assert_eq!(trash_entries[1].original_parent, root_path);
 }
 
 #[gpui::test]
@@ -709,36 +691,14 @@ async fn test_fake_fs_restore(executor: BackgroundExecutor) {
     )
     .await;
 
-    // Providing a non-existent `TrashedEntry` should result in an error.
-    let id = OsString::from("/trash/file_c.txt");
-    let name = OsString::from("file_c.txt");
-    let original_parent = PathBuf::from(path!("/root"));
-    let trashed_entry = TrashedEntry {
-        id,
-        name,
-        original_parent,
-    };
-    let result = fs.restore(trashed_entry).await;
-    assert!(matches!(result, Err(TrashRestoreError::NotFound { .. })));
-
     // Attempt deleting a file, asserting that the filesystem no longer reports
     // it as part of its list of files, restore it and verify that the list of
     // files and trash has been updated accordingly.
     let path = path!("/root/src/file_a.txt").as_ref();
     let trashed_entry = fs.trash(path, Default::default()).await.unwrap();
 
-    assert_eq!(fs.trash_entries().len(), 1);
-    assert_eq!(
-        fs.files(),
-        vec![
-            PathBuf::from(path!("/root/file_c.txt")),
-            PathBuf::from(path!("/root/src/file_b.txt"))
-        ]
-    );
-
     fs.restore(trashed_entry).await.unwrap();
 
-    assert_eq!(fs.trash_entries().len(), 0);
     assert_eq!(
         fs.files(),
         vec![
@@ -758,7 +718,6 @@ async fn test_fake_fs_restore(executor: BackgroundExecutor) {
     let path = path!("/root/src/").as_ref();
     let trashed_entry = fs.trash(path, options).await.unwrap();
 
-    assert_eq!(fs.trash_entries().len(), 1);
     assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
 
     fs.restore(trashed_entry).await.unwrap();
@@ -771,14 +730,12 @@ async fn test_fake_fs_restore(executor: BackgroundExecutor) {
             PathBuf::from(path!("/root/src/file_b.txt"))
         ]
     );
-    assert_eq!(fs.trash_entries().len(), 0);
 
     // A collision error should be returned in case a file is being restored to
     // a path where a file already exists.
     let path = path!("/root/src/file_a.txt").as_ref();
     let trashed_entry = fs.trash(path, Default::default()).await.unwrap();
 
-    assert_eq!(fs.trash_entries().len(), 1);
     assert_eq!(
         fs.files(),
         vec![
@@ -789,7 +746,6 @@ async fn test_fake_fs_restore(executor: BackgroundExecutor) {
 
     fs.write(path, "New File A".as_bytes()).await.unwrap();
 
-    assert_eq!(fs.trash_entries().len(), 1);
     assert_eq!(
         fs.files(),
         vec![
@@ -815,19 +771,16 @@ async fn test_fake_fs_restore(executor: BackgroundExecutor) {
     let path = path!("/root/src/").as_ref();
     let trashed_entry = fs.trash(path, options).await.unwrap();
 
-    assert_eq!(fs.trash_entries().len(), 2);
     assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
 
     fs.create_dir(path).await.unwrap();
 
     assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
-    assert_eq!(fs.trash_entries().len(), 2);
 
     let result = fs.restore(trashed_entry).await;
     assert!(result.is_err());
 
     assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
-    assert_eq!(fs.trash_entries().len(), 2);
 }
 
 /// Create a directory symlink (`link` -> `target`) in a cross-platform way.
diff --git a/crates/fuzzy_nucleo/benches/match_benchmark.rs b/crates/fuzzy_nucleo/benches/match_benchmark.rs
index 8f6eedce491613..ec1607a2bd6c4f 100644
--- a/crates/fuzzy_nucleo/benches/match_benchmark.rs
+++ b/crates/fuzzy_nucleo/benches/match_benchmark.rs
@@ -233,7 +233,11 @@ fn generate_nucleo_path_candidates(
     paths
         .iter()
         .map(|path| {
-            fuzzy_nucleo::PathMatchCandidate::new(RelPath::unix(path).unwrap(), false, None)
+            fuzzy_nucleo::PathMatchCandidate::new(
+                RelPath::from_unix_str(path).unwrap(),
+                false,
+                None,
+            )
         })
         .collect()
 }
@@ -245,7 +249,7 @@ fn generate_fuzzy_path_candidates(
         .iter()
         .map(|path| fuzzy::PathMatchCandidate {
             is_dir: false,
-            path: RelPath::unix(path).unwrap(),
+            path: RelPath::from_unix_str(path).unwrap(),
             char_bag: CharBag::from(path.as_str()),
         })
         .collect()
@@ -302,7 +306,7 @@ fn bench_path_matching(criterion: &mut Criterion) {
                             query,
                             case,
                             size,
-                            PathStyle::Posix,
+                            PathStyle::Unix,
                         )
                     },
                     BatchSize::SmallInput,
@@ -325,7 +329,7 @@ fn bench_path_matching(criterion: &mut Criterion) {
                             query,
                             false,
                             size,
-                            PathStyle::Posix,
+                            PathStyle::Unix,
                         )
                     },
                     BatchSize::SmallInput,
diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs
index dd905f521fe9b7..6036e112e43704 100644
--- a/crates/git/src/blame.rs
+++ b/crates/git/src/blame.rs
@@ -1,9 +1,9 @@
 use crate::Oid;
-use crate::commit::get_messages;
+use crate::commit::{get_messages, get_tag_names};
 use crate::repository::{GitBinary, RepoPath};
 use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet};
-use futures::{AsyncWriteExt, try_join};
+use futures::{AsyncWriteExt, TryFutureExt, try_join};
 use serde::{Deserialize, Serialize};
 use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
 use std::ops::Range;
@@ -17,6 +17,7 @@ use util::command::Stdio;
 pub struct Blame {
     pub entries: Vec,
     pub messages: HashMap,
+    pub tag_names: HashMap>,
 }
 
 impl Blame {
@@ -35,12 +36,26 @@ impl Blame {
         }
 
         let shas = unique_shas.into_iter().collect::>();
-        let messages = get_messages(git, &shas)
-            .await
-            .context("failed to get commit messages")?;
+        let (messages, tag_names) = try_join!(
+            get_messages(git, &shas)
+                .map_err(|error| error.context("failed to get commit messages")),
+            async {
+                match get_tag_names(git, &shas).await {
+                    Ok(tag_names) => Ok(tag_names),
+                    Err(error) => {
+                        log::warn!("failed to get commit tag names: {error:#}");
+                        Ok(HashMap::default())
+                    }
+                }
+            },
+        )?;
 
         entries.sort_unstable_by_key(|entry| entry.range.start);
-        Ok(Self { entries, messages })
+        Ok(Self {
+            entries,
+            messages,
+            tag_names,
+        })
     }
 }
 
diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs
index 50b62fa506bc31..e7f367fb22b752 100644
--- a/crates/git/src/commit.rs
+++ b/crates/git/src/commit.rs
@@ -3,9 +3,9 @@ use crate::{
     repository::GitBinary, status::StatusCode,
 };
 use anyhow::{Context as _, Result};
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use gpui::SharedString;
-use std::sync::Arc;
+use std::{str::FromStr, sync::Arc};
 
 #[derive(Clone, Debug, Default)]
 pub struct ParsedCommitMessage {
@@ -78,6 +78,60 @@ pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result>())
 }
 
+pub(crate) async fn get_tag_names(
+    git: &GitBinary,
+    shas: &[Oid],
+) -> Result>> {
+    if shas.is_empty() {
+        return Ok(HashMap::default());
+    }
+
+    let output = git
+        .build_command(&[
+            "for-each-ref",
+            "refs/tags",
+            "--sort=-creatordate",
+            "--format=%(objectname)%00%(*objectname)%00%(refname:short)",
+        ])
+        .output()
+        .await
+        .context("starting git for-each-ref process")?;
+    anyhow::ensure!(
+        output.status.success(),
+        "'git for-each-ref' failed with error {:?}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    Ok(parse_tag_names(
+        &String::from_utf8_lossy(&output.stdout),
+        shas,
+    ))
+}
+
+fn parse_tag_names(output: &str, shas: &[Oid]) -> HashMap> {
+    let shas = shas.iter().copied().collect::>();
+    let mut result = HashMap::>::default();
+
+    for line in output.lines() {
+        let mut fields = line.split('\0');
+        let object_sha = fields.next();
+        let peeled_sha = fields.next().filter(|sha| !sha.is_empty());
+        let Some(sha) = peeled_sha
+            .or(object_sha)
+            .and_then(|sha| Oid::from_str(sha).ok())
+        else {
+            continue;
+        };
+        let Some(tag_name) = fields.next().filter(|tag_name| !tag_name.is_empty()) else {
+            continue;
+        };
+        result.entry(sha).or_default().push(tag_name.to_string());
+    }
+
+    result.retain(|sha, _| shas.contains(sha));
+    result
+}
+
 async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result> {
     const MARKER: &str = "";
     let output = git
@@ -100,21 +154,88 @@ async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result>
         .collect::>())
 }
 
-/// Parse the output of `git diff --name-status -z`
-pub fn parse_git_diff_name_status(content: &str) -> impl Iterator {
+pub(crate) const GITLINK_MODE: &str = "160000";
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum CommitDiffObjectKind {
+    Blob,
+    Gitlink,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct CommitDiffObject<'a> {
+    pub oid: &'a str,
+    pub kind: CommitDiffObjectKind,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct CommitDiffEntry<'a> {
+    pub path: &'a str,
+    pub status: StatusCode,
+    pub old_object: Option>,
+    pub new_object: Option>,
+}
+
+/// Parses the output of `git diff --raw --no-abbrev -z`.
+pub(crate) fn parse_git_diff_raw(
+    content: &str,
+) -> impl Iterator>> {
     let mut parts = content.split('\0');
     std::iter::from_fn(move || {
-        loop {
-            let status_str = parts.next()?;
-            let path = parts.next()?;
-            let status = match status_str {
-                "M" => StatusCode::Modified,
-                "A" => StatusCode::Added,
-                "D" => StatusCode::Deleted,
-                _ => continue,
-            };
-            return Some((path, status));
+        let metadata = parts.next()?;
+        if metadata.is_empty() {
+            return None;
         }
+
+        let path = match parts.next() {
+            Some(path) => path,
+            None => return Some(Err(anyhow::anyhow!("raw diff is missing the path"))),
+        };
+        Some(parse_git_diff_raw_entry(metadata, path))
+    })
+}
+
+fn parse_git_diff_raw_entry<'a>(metadata: &'a str, path: &'a str) -> Result> {
+    let mut fields = metadata
+        .strip_prefix(':')
+        .context("raw diff metadata is missing its ':' prefix")?
+        .split_ascii_whitespace();
+    let old_mode = fields.next().context("raw diff is missing the old mode")?;
+    let new_mode = fields.next().context("raw diff is missing the new mode")?;
+    let old_oid = fields
+        .next()
+        .context("raw diff is missing the old object ID")?;
+    let new_oid = fields
+        .next()
+        .context("raw diff is missing the new object ID")?;
+    let status = match fields.next() {
+        Some("M") => StatusCode::Modified,
+        Some("T") => StatusCode::TypeChanged,
+        Some("A") => StatusCode::Added,
+        Some("D") => StatusCode::Deleted,
+        Some(status) => anyhow::bail!("unsupported raw diff status {status}"),
+        None => anyhow::bail!("raw diff is missing the status"),
+    };
+
+    Ok(CommitDiffEntry {
+        path,
+        status,
+        old_object: (!old_oid.bytes().all(|byte| byte == b'0')).then(|| CommitDiffObject {
+            oid: old_oid,
+            kind: if old_mode == GITLINK_MODE {
+                CommitDiffObjectKind::Gitlink
+            } else {
+                CommitDiffObjectKind::Blob
+            },
+        }),
+        new_object: (!new_oid.bytes().all(|byte| byte == b'0')).then(|| CommitDiffObject {
+            oid: new_oid,
+            kind: if new_mode == GITLINK_MODE {
+                CommitDiffObjectKind::Gitlink
+            } else {
+                CommitDiffObjectKind::Blob
+            },
+        }),
     })
 }
 
@@ -124,39 +245,82 @@ mod tests {
     use super::*;
 
     #[test]
-    fn test_parse_git_diff_name_status() {
+    fn test_parse_git_diff_raw() {
         let input = concat!(
-            "M\x00Cargo.lock\x00",
-            "M\x00crates/project/Cargo.toml\x00",
-            "M\x00crates/project/src/buffer_store.rs\x00",
-            "D\x00crates/project/src/git.rs\x00",
-            "A\x00crates/project/src/git_store.rs\x00",
-            "A\x00crates/project/src/git_store/git_traversal.rs\x00",
-            "M\x00crates/project/src/project.rs\x00",
-            "M\x00crates/project/src/worktree_store.rs\x00",
-            "M\x00crates/project_panel/src/project_panel.rs\x00",
+            ":100644 100644 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 M\x00file.txt\x00",
+            ":160000 160000 3333333333333333333333333333333333333333 4444444444444444444444444444444444444444 M\x00modules/example\x00",
+            ":000000 100644 0000000000000000000000000000000000000000 5555555555555555555555555555555555555555 A\x00added.txt\x00",
+            ":160000 000000 6666666666666666666666666666666666666666 0000000000000000000000000000000000000000 D\x00deleted-module\x00",
+            ":100644 160000 7777777777777777777777777777777777777777 8888888888888888888888888888888888888888 T\x00type-change\x00",
         );
 
-        let output = parse_git_diff_name_status(input).collect::>();
+        let entries = parse_git_diff_raw(input)
+            .collect::>>()
+            .unwrap();
+        let [file, gitlink, added, deleted, type_change] = entries.as_slice() else {
+            panic!("expected five raw diff entries");
+        };
+
+        assert_eq!(file.path, "file.txt");
+        assert_eq!(file.status, StatusCode::Modified);
         assert_eq!(
-            output,
-            &[
-                ("Cargo.lock", StatusCode::Modified),
-                ("crates/project/Cargo.toml", StatusCode::Modified),
-                ("crates/project/src/buffer_store.rs", StatusCode::Modified),
-                ("crates/project/src/git.rs", StatusCode::Deleted),
-                ("crates/project/src/git_store.rs", StatusCode::Added),
-                (
-                    "crates/project/src/git_store/git_traversal.rs",
-                    StatusCode::Added,
-                ),
-                ("crates/project/src/project.rs", StatusCode::Modified),
-                ("crates/project/src/worktree_store.rs", StatusCode::Modified),
-                (
-                    "crates/project_panel/src/project_panel.rs",
-                    StatusCode::Modified
-                ),
-            ]
+            file.new_object.map(|object| object.kind),
+            Some(CommitDiffObjectKind::Blob)
+        );
+        assert_eq!(gitlink.path, "modules/example");
+        assert_eq!(gitlink.status, StatusCode::Modified);
+        assert_eq!(
+            gitlink.old_object.map(|object| object.kind),
+            Some(CommitDiffObjectKind::Gitlink)
+        );
+        assert_eq!(
+            gitlink.new_object.map(|object| object.kind),
+            Some(CommitDiffObjectKind::Gitlink)
+        );
+        assert!(added.old_object.is_none());
+        assert_eq!(added.status, StatusCode::Added);
+        assert!(deleted.new_object.is_none());
+        assert_eq!(deleted.status, StatusCode::Deleted);
+        assert_eq!(type_change.status, StatusCode::TypeChanged);
+        assert_eq!(
+            type_change.old_object.map(|object| object.kind),
+            Some(CommitDiffObjectKind::Blob)
+        );
+        assert_eq!(
+            type_change.new_object.map(|object| object.kind),
+            Some(CommitDiffObjectKind::Gitlink)
+        );
+    }
+
+    #[test]
+    fn test_parse_git_diff_raw_rejects_malformed_metadata() {
+        let error = parse_git_diff_raw(":100644\x00file.txt\x00")
+            .next()
+            .expect("expected a raw diff entry")
+            .expect_err("expected malformed metadata to fail");
+        assert!(error.to_string().contains("new mode"));
+    }
+
+    #[test]
+    fn test_parse_tag_names_for_lightweight_and_annotated_tags() -> Result<()> {
+        let tagged_commit = Oid::from_str("1111111111111111111111111111111111111111")?;
+        let tag_object = Oid::from_str("2222222222222222222222222222222222222222")?;
+        let other_commit = Oid::from_str("3333333333333333333333333333333333333333")?;
+        let output = format!(
+            "{tagged_commit}\0\0v1.0.0\n\
+             {tag_object}\0{tagged_commit}\0v1.1.0\n\
+             {other_commit}\0\0ignored\n"
+        );
+
+        let parsed = parse_tag_names(&output, &[tagged_commit]);
+
+        assert_eq!(
+            parsed,
+            HashMap::from_iter([(
+                tagged_commit,
+                vec![String::from("v1.0.0"), String::from("v1.1.0")]
+            )])
         );
+        Ok(())
     }
 }
diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs
index fe9947f23b5b83..4bb16a39515dce 100644
--- a/crates/git/src/git.rs
+++ b/crates/git/src/git.rs
@@ -21,6 +21,8 @@ pub const GITIGNORE: &str = ".gitignore";
 pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
 pub const LFS_DIR: &str = "lfs";
 pub const OBJECTS_DIR: &str = "objects";
+pub const REFS_DIR: &str = "refs";
+pub const REFTABLE_DIR: &str = "reftable";
 pub const HOOKS_DIR: &str = "hooks";
 pub const LOGS_DIR: &str = "logs";
 pub const LOGS_REF_STASH: &str = "logs/refs/stash";
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index 93338de75ccf4a..c4daf3ff87a660 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -1,6 +1,6 @@
-use crate::commit::parse_git_diff_name_status;
+use crate::commit::{CommitDiffObject, CommitDiffObjectKind, parse_git_diff_raw};
 use crate::stash::GitStash;
-use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
+use crate::status::{DiffTreeType, GitStatus, TreeDiff};
 use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
 use anyhow::{Context as _, Result, anyhow, bail};
 use async_channel::Sender;
@@ -18,6 +18,7 @@ use smallvec::SmallVec;
 use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
 use text::LineEnding;
 
+use std::borrow::Cow;
 use std::collections::HashSet;
 use std::ffi::{OsStr, OsString};
 use std::sync::atomic::AtomicBool;
@@ -571,6 +572,57 @@ pub fn is_binary_content(content: &[u8]) -> bool {
     content[..check_len].contains(&0)
 }
 
+struct LoadedCommitObject {
+    text: String,
+    is_binary: bool,
+}
+
+async fn read_commit_blob(
+    stdout: &mut R,
+    info_line: &mut String,
+    newline: &mut [u8; 1],
+) -> Result {
+    info_line.clear();
+    stdout.read_line(info_line).await?;
+
+    let len = info_line
+        .trim_end()
+        .parse()
+        .with_context(|| format!("invalid object size output from cat-file {info_line}"))?;
+
+    let mut bytes = vec![0; len];
+    stdout.read_exact(&mut bytes).await?;
+    stdout.read_exact(newline).await?;
+
+    let is_binary = is_binary_content(&bytes);
+    Ok(LoadedCommitObject {
+        text: if is_binary {
+            String::new()
+        } else {
+            String::from_utf8_lossy(&bytes).to_string()
+        },
+        is_binary,
+    })
+}
+
+async fn load_commit_object(
+    object: Option>,
+    stdout: &mut R,
+    info_line: &mut String,
+    newline: &mut [u8; 1],
+) -> Result> {
+    match object {
+        Some(object) if object.kind == CommitDiffObjectKind::Gitlink => {
+            Ok(Some(LoadedCommitObject {
+                text: format!("Subproject commit {}\n", object.oid),
+                is_binary: false,
+            }))
+        }
+        Some(_) => Ok(Some(read_commit_blob(stdout, info_line, newline).await?)),
+        None => Ok(None),
+    }
+}
+
 #[derive(Debug, Clone, Hash, PartialEq, Eq)]
 pub struct Remote {
     pub name: SharedString,
@@ -757,20 +809,22 @@ pub enum LogSource {
 }
 
 impl LogSource {
-    fn get_args(&self) -> Result> {
+    fn get_args(&self) -> Vec> {
         match self {
-            LogSource::All => Ok(vec![
-                "--ignore-missing", // needed in case of unborn HEAD
-                "--branches",
-                "--remotes",
-                "--tags",
-                "HEAD",
-            ]),
-            LogSource::Branch(branch) => Ok(vec![branch.as_str()]),
-            LogSource::Sha(oid) => Ok(vec![
-                str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")?,
-            ]),
-            LogSource::Path(path) => Ok(vec!["--follow", "--", path.as_unix_str()]),
+            LogSource::All => vec![
+                Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD
+                Cow::Borrowed("--branches"),
+                Cow::Borrowed("--remotes"),
+                Cow::Borrowed("--tags"),
+                Cow::Borrowed("HEAD"),
+            ],
+            LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())],
+            LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())],
+            LogSource::Path(path) => vec![
+                Cow::Borrowed("--follow"),
+                Cow::Borrowed("--"),
+                Cow::Borrowed(path.as_unix_str()),
+            ],
         }
     }
 }
@@ -801,12 +855,18 @@ pub trait GitRepository: Send + Sync {
     /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
     ///
     /// Also returns `None` for symlinks.
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option>;
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
+        let future = self.load_revisions(vec![format!(":{}", path.as_unix_str())]);
+        async move { future.await.ok()?.pop()? }.boxed()
+    }
 
     /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
     ///
     /// Also returns `None` for symlinks.
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option>;
+    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
+        let future = self.load_revisions(vec![format!("HEAD:{}", path.as_unix_str())]);
+        async move { future.await.ok()?.pop()? }.boxed()
+    }
     fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result>;
 
     fn set_index_text(
@@ -830,6 +890,8 @@ pub trait GitRepository: Send + Sync {
     /// Resolve a list of refs to SHAs.
     fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>;
 
+    fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>>;
+
     fn head_sha(&self) -> BoxFuture<'_, Option> {
         async move {
             self.revparse_batch(vec!["HEAD".into()])
@@ -951,6 +1013,10 @@ pub trait GitRepository: Send + Sync {
         env: Arc>,
     ) -> BoxFuture<'_, Result<()>>;
 
+    /// Only used to serve `proto::RunGitHook` requests from older remote clients;
+    /// new code lets `git commit` run hooks itself.
+    ///
+    /// TODO: remove together with `proto::RunGitHook` (see the deprecation note in git.proto).
     fn run_hook(
         &self,
         hook: RunHook,
@@ -1043,6 +1109,7 @@ pub trait GitRepository: Send + Sync {
 
     fn diff_stat(
         &self,
+        diff: DiffStatType,
         path_prefixes: &[RepoPath],
     ) -> BoxFuture<'static, Result>;
 
@@ -1127,6 +1194,13 @@ pub enum DiffType {
     MergeBase { base_ref: SharedString },
 }
 
+#[derive(Clone, Copy)]
+pub enum DiffStatType {
+    HeadToIndex,
+    HeadToWorktree,
+    IndexToWorktree,
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 pub enum PushOptions {
     SetUpstream,
@@ -1410,7 +1484,8 @@ impl GitRepository for RealGitRepository {
                     "--format=",
                     "-z",
                     "--no-renames",
-                    "--name-status",
+                    "--raw",
+                    "--no-abbrev",
                     "--first-parent",
                 ])
                 .arg(&commit)
@@ -1420,10 +1495,14 @@ impl GitRepository for RealGitRepository {
                 .output()
                 .await
                 .context("starting git show process")?;
+            anyhow::ensure!(
+                show_output.status.success(),
+                "git show failed: {}",
+                String::from_utf8_lossy(&show_output.stderr)
+            );
 
             let show_stdout = String::from_utf8_lossy(&show_output.stdout);
-            let changes = parse_git_diff_name_status(&show_stdout);
-            let parent_sha = format!("{}^", commit);
+            let changes = parse_git_diff_raw(&show_stdout);
 
             let mut cat_file_process = git
                 .build_command(&["cat-file", "--batch=%(objectsize)"])
@@ -1434,85 +1513,62 @@ impl GitRepository for RealGitRepository {
                 .context("starting git cat-file process")?;
 
             let mut files = Vec::::new();
-            let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
-            let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
+            let stdin = cat_file_process
+                .stdin
+                .take()
+                .context("git cat-file process has no stdin")?;
+            let stdout = cat_file_process
+                .stdout
+                .take()
+                .context("git cat-file process has no stdout")?;
+            let mut stdin = BufWriter::with_capacity(512, stdin);
+            let mut stdout = BufReader::new(stdout);
             let mut info_line = String::new();
             let mut newline = [b'\0'];
-            for (path, status_code) in changes {
+            for change in changes {
+                let change = change?;
+                let path = change.path;
                 // git-show outputs `/`-delimited paths even on Windows.
-                let Some(rel_path) = RelPath::unix(path).log_err() else {
+                let Some(rel_path) = RelPath::from_unix_str(path).log_err() else {
                     continue;
                 };
 
-                match status_code {
-                    StatusCode::Modified => {
-                        stdin.write_all(commit.as_bytes()).await?;
-                        stdin.write_all(b":").await?;
-                        stdin.write_all(path.as_bytes()).await?;
-                        stdin.write_all(b"\n").await?;
-                        stdin.write_all(parent_sha.as_bytes()).await?;
-                        stdin.write_all(b":").await?;
-                        stdin.write_all(path.as_bytes()).await?;
-                        stdin.write_all(b"\n").await?;
-                    }
-                    StatusCode::Added => {
-                        stdin.write_all(commit.as_bytes()).await?;
-                        stdin.write_all(b":").await?;
-                        stdin.write_all(path.as_bytes()).await?;
-                        stdin.write_all(b"\n").await?;
-                    }
-                    StatusCode::Deleted => {
-                        stdin.write_all(parent_sha.as_bytes()).await?;
-                        stdin.write_all(b":").await?;
-                        stdin.write_all(path.as_bytes()).await?;
+                let objects = [change.new_object, change.old_object];
+                let mut has_blobs = false;
+                for object in objects.iter().flatten() {
+                    if object.kind == CommitDiffObjectKind::Blob {
+                        stdin.write_all(object.oid.as_bytes()).await?;
                         stdin.write_all(b"\n").await?;
+                        has_blobs = true;
                     }
-                    _ => continue,
                 }
-                stdin.flush().await?;
-
-                info_line.clear();
-                stdout.read_line(&mut info_line).await?;
-
-                let len = info_line.trim_end().parse().with_context(|| {
-                    format!("invalid object size output from cat-file {info_line}")
-                })?;
-                let mut text_bytes = vec![0; len];
-                stdout.read_exact(&mut text_bytes).await?;
-                stdout.read_exact(&mut newline).await?;
-
-                let mut old_text = None;
-                let mut new_text = None;
-                let mut is_binary = is_binary_content(&text_bytes);
-                let text = if is_binary {
-                    String::new()
-                } else {
-                    String::from_utf8_lossy(&text_bytes).to_string()
-                };
+                if has_blobs {
+                    stdin.flush().await?;
+                }
 
-                match status_code {
-                    StatusCode::Modified => {
-                        info_line.clear();
-                        stdout.read_line(&mut info_line).await?;
-                        let len = info_line.trim_end().parse().with_context(|| {
-                            format!("invalid object size output from cat-file {}", info_line)
-                        })?;
-                        let mut parent_bytes = vec![0; len];
-                        stdout.read_exact(&mut parent_bytes).await?;
-                        stdout.read_exact(&mut newline).await?;
-                        is_binary = is_binary || is_binary_content(&parent_bytes);
-                        if is_binary {
-                            old_text = Some(String::new());
-                            new_text = Some(String::new());
-                        } else {
-                            old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
-                            new_text = Some(text);
-                        }
+                let [new_object, old_object] = objects;
+                let new_object =
+                    load_commit_object(new_object, &mut stdout, &mut info_line, &mut newline)
+                        .await?;
+                let old_object =
+                    load_commit_object(old_object, &mut stdout, &mut info_line, &mut newline)
+                        .await?;
+                let is_binary = new_object.as_ref().is_some_and(|object| object.is_binary)
+                    || old_object.as_ref().is_some_and(|object| object.is_binary);
+                let new_text = new_object.map(|object| {
+                    if is_binary {
+                        String::new()
+                    } else {
+                        object.text
                     }
-                    StatusCode::Added => new_text = Some(text),
-                    StatusCode::Deleted => old_text = Some(text),
-                    _ => continue,
-                }
+                });
+                let old_text = old_object.map(|object| {
+                    if is_binary {
+                        String::new()
+                    } else {
+                        object.text
+                    }
+                });
 
                 files.push(CommitFile {
                     path: RepoPath(Arc::from(rel_path)),
@@ -1585,47 +1641,6 @@ impl GitRepository for RealGitRepository {
         .boxed()
     }
 
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let git_binary = self.git_binary();
-        let path_str = format!(":{}", path.as_unix_str());
-        self.executor
-            .spawn(async move {
-                let git = git_binary;
-                let output = git
-                    .build_command(&["show", &path_str])
-                    .stdout(Stdio::piped())
-                    .stderr(Stdio::piped())
-                    .output()
-                    .await
-                    .log_err()?;
-                if !output.status.success() {
-                    return None;
-                }
-                String::from_utf8(output.stdout).ok()
-            })
-            .boxed()
-    }
-
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let git = self.git_binary();
-        let path_str = format!("HEAD:{}", path.as_unix_str());
-        self.executor
-            .spawn(async move {
-                let output = git
-                    .build_command(&["show", &path_str])
-                    .stdout(Stdio::piped())
-                    .stderr(Stdio::piped())
-                    .output()
-                    .await
-                    .log_err()?;
-                if !output.status.success() {
-                    return None;
-                }
-                String::from_utf8(output.stdout).ok()
-            })
-            .boxed()
-    }
-
     fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result> {
         let git_binary = self.git_binary();
         let oid_str = oid.to_string();
@@ -1801,6 +1816,68 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>> {
+        let git = self.git_binary();
+        self.executor
+            .spawn(async move {
+                if revisions.is_empty() {
+                    return Ok(Vec::new());
+                }
+
+                let mut process = git
+                    .build_command(&["cat-file", "--batch"])
+                    .stdin(Stdio::piped())
+                    .stdout(Stdio::piped())
+                    .stderr(Stdio::piped())
+                    .kill_on_drop(true)
+                    .spawn()?;
+
+                let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
+                let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
+                let mut newline = [0u8; 1];
+
+                let mut header_bytes = Vec::new();
+                let mut results = Vec::with_capacity(revisions.len());
+                for rev in &revisions {
+                    stdin.write_all(rev.as_bytes()).await?;
+                    stdin.write_all(b"\n").await?;
+                    stdin.flush().await?;
+
+                    header_bytes.clear();
+                    stdout.read_until(b'\n', &mut header_bytes).await?;
+                    let header_line = String::from_utf8_lossy(&header_bytes);
+
+                    let parts: Vec<&str> = header_line.trim().split(' ').collect();
+                    match parts[..] {
+                        [.., "missing"] => {
+                            results.push(None);
+                        }
+                        [_, object_type, size_str] => {
+                            let size: usize = size_str
+                                .parse()
+                                .with_context(|| format!("invalid object size: {size_str}"))?;
+
+                            let mut content = vec![0u8; size];
+                            stdout.read_exact(&mut content).await?;
+                            stdout.read_exact(&mut newline).await?;
+
+                            if object_type == "blob" {
+                                results.push(String::from_utf8(content).ok());
+                            } else {
+                                results.push(None);
+                            }
+                        }
+                        _ => bail!("invalid cat-file header: {header_line}"),
+                    }
+                }
+
+                drop(stdin);
+                process.output().await?;
+                Ok(results)
+            })
+            .boxed()
+    }
+
     fn merge_message(&self) -> BoxFuture<'_, Option> {
         let path = self.path().join("MERGE_MSG");
         self.executor
@@ -2131,6 +2208,13 @@ impl GitRepository for RealGitRepository {
                     .await
                     .is_ok()
                 {
+                    let name = match git_binary.run(&["symbolic-ref", &remote_ref]).await {
+                        Ok(resolved) => resolved
+                            .strip_prefix("refs/remotes/")
+                            .map(str::to_owned)
+                            .unwrap_or(name),
+                        Err(_) => name,
+                    };
                     let (_, branch_name) =
                         name.split_once('/').context("Unexpected branch format")?;
                     let local_branch_ref = format!("refs/heads/{branch_name}");
@@ -2257,6 +2341,7 @@ impl GitRepository for RealGitRepository {
 
     fn diff_stat(
         &self,
+        diff: DiffStatType,
         path_prefixes: &[RepoPath],
     ) -> BoxFuture<'static, Result> {
         let path_prefixes = path_prefixes.to_vec();
@@ -2265,12 +2350,13 @@ impl GitRepository for RealGitRepository {
         self.executor
             .spawn(async move {
                 let git_binary = git_binary?;
-                let mut args: Vec = vec![
-                    "diff".into(),
-                    "--numstat".into(),
-                    "--no-renames".into(),
-                    "HEAD".into(),
-                ];
+                let mut args: Vec =
+                    vec!["diff".into(), "--numstat".into(), "--no-renames".into()];
+                match diff {
+                    DiffStatType::HeadToIndex => args.extend(["--cached".into(), "HEAD".into()]),
+                    DiffStatType::HeadToWorktree => args.push("HEAD".into()),
+                    DiffStatType::IndexToWorktree => {}
+                }
                 if !path_prefixes.is_empty() {
                     args.push("--".into());
                     args.extend(
@@ -2460,7 +2546,6 @@ impl GitRepository for RealGitRepository {
             cmd.envs(env.iter())
                 .arg(&message.to_string())
                 .arg("--cleanup=strip")
-                .arg("--no-verify")
                 .stdout(Stdio::piped())
                 .stderr(Stdio::piped());
 
@@ -3075,8 +3160,9 @@ impl GitRepository for RealGitRepository {
         let git = self.git_binary();
 
         async move {
+            let log_source_args = log_source.get_args();
             let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
-            git_log_command.extend(log_source.get_args()?);
+            git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref()));
             let mut command = git.build_command(&git_log_command);
             command.stdout(Stdio::piped());
             command.stderr(Stdio::piped());
@@ -3146,6 +3232,7 @@ impl GitRepository for RealGitRepository {
         let git = self.git_binary();
 
         async move {
+            let log_source_args = log_source.get_args();
             let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
             let hash_query = commit_hash_search_query(search_args.query.as_str())
                 .map(|query| query.to_ascii_lowercase());
@@ -3161,7 +3248,7 @@ impl GitRepository for RealGitRepository {
                 args.push(search_args.query.as_str());
             }
 
-            args.extend(log_source.get_args()?);
+            args.extend(log_source_args.iter().map(|arg| arg.as_ref()));
             let mut command = git.build_command(&args);
             command.stdout(Stdio::piped());
             command.stderr(Stdio::null());
@@ -3670,6 +3757,16 @@ async fn run_git_command(
             .env("GIT_ASKPASS", ask_pass.script_path())
             .env("SSH_ASKPASS", ask_pass.script_path())
             .env("SSH_ASKPASS_REQUIRE", "force");
+
+        if !env.contains_key("GIT_CONFIG_COUNT")
+            && let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path()
+        {
+            command
+                .env("GIT_CONFIG_COUNT", "1")
+                .env("GIT_CONFIG_KEY_0", "gpg.program")
+                .env("GIT_CONFIG_VALUE_0", gpg_wrapper);
+        }
+
         #[cfg(target_os = "windows")]
         command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path());
         let git_process = command.spawn()?;
@@ -3683,12 +3780,15 @@ async fn run_askpass_command(
     git_process: util::command::Child,
 ) -> anyhow::Result {
     select_biased! {
-        result = ask_pass.run().fuse() => {
+        // Git can legitimately run long without prompting (e.g. large fetches,
+        // hooks), so completion is determined by the process itself.
+        result = ask_pass.run(None).fuse() => {
             match result {
                 AskPassResult::CancelledByUser => {
                     Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
                 }
                 AskPassResult::Timedout => {
+                    // Unreachable since no timeout is passed to run()
                     Err(anyhow!("Connecting to host timed out"))?
                 }
             }
@@ -3719,7 +3819,7 @@ impl std::fmt::Debug for RepoPath {
 
 impl RepoPath {
     pub fn new + ?Sized>(s: &S) -> Result {
-        let rel_path = RelPath::unix(s.as_ref())?;
+        let rel_path = RelPath::from_unix_str(s.as_ref())?;
         Ok(Self::from_rel_path(rel_path))
     }
 
@@ -3729,7 +3829,7 @@ impl RepoPath {
     }
 
     pub fn from_proto(proto: &str) -> Result {
-        let rel_path = RelPath::from_proto(proto)?;
+        let rel_path = RelPath::from_unix_str(proto)?.into();
         Ok(Self(rel_path))
     }
 
@@ -3748,7 +3848,7 @@ impl RepoPath {
 
 #[cfg(any(test, feature = "test-support"))]
 pub fn repo_path + ?Sized>(s: &S) -> RepoPath {
-    RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
+    RepoPath(RelPath::from_unix_str(s.as_ref()).unwrap().into())
 }
 
 impl AsRef> for RepoPath {
@@ -3906,7 +4006,7 @@ mod tests {
 
     #[allow(clippy::disallowed_methods)]
     #[track_caller]
-    fn git_command(working_directory: &Path, arguments: I)
+    fn git_command_output(working_directory: &Path, arguments: I) -> String
     where
         I: IntoIterator,
         S: AsRef,
@@ -3927,6 +4027,19 @@ mod tests {
             "git command failed: {}",
             String::from_utf8_lossy(&output.stderr)
         );
+        String::from_utf8(output.stdout)
+            .expect("git command output was not valid UTF-8")
+            .trim()
+            .to_string()
+    }
+
+    #[track_caller]
+    fn git_command(working_directory: &Path, arguments: I)
+    where
+        I: IntoIterator,
+        S: AsRef,
+    {
+        git_command_output(working_directory, arguments);
     }
 
     fn git_init_repo(path: &Path) {
@@ -3934,6 +4047,52 @@ mod tests {
         git_command(path, ["init", "-b", "main"]);
     }
 
+    fn clone_remote_repository_with_main_and_feature(temp_dir: &Path) -> (PathBuf, PathBuf) {
+        let remote_directory = temp_dir.join("remote.git");
+        let seed_directory = temp_dir.join("seed");
+        let clone_directory = temp_dir.join("clone");
+
+        git_command(
+            temp_dir,
+            [
+                OsString::from("init"),
+                OsString::from("--bare"),
+                OsString::from("-b"),
+                OsString::from("main"),
+                remote_directory.as_os_str().into(),
+            ],
+        );
+        git_init_repo(&seed_directory);
+        fs::write(seed_directory.join("file.txt"), "main").unwrap();
+        git_command(&seed_directory, ["add", "file.txt"]);
+        git_command(&seed_directory, ["commit", "-m", "initial"]);
+        git_command(&seed_directory, ["switch", "-c", "feature"]);
+        fs::write(seed_directory.join("feature.txt"), "feature").unwrap();
+        git_command(&seed_directory, ["add", "feature.txt"]);
+        git_command(&seed_directory, ["commit", "-m", "feature"]);
+        git_command(
+            &seed_directory,
+            [
+                OsString::from("remote"),
+                OsString::from("add"),
+                OsString::from("origin"),
+                remote_directory.as_os_str().into(),
+            ],
+        );
+        git_command(&seed_directory, ["push", "-u", "origin", "main"]);
+        git_command(&seed_directory, ["push", "-u", "origin", "feature"]);
+        git_command(
+            temp_dir,
+            [
+                OsString::from("clone"),
+                remote_directory.as_os_str().into(),
+                clone_directory.as_os_str().into(),
+            ],
+        );
+
+        (remote_directory, clone_directory)
+    }
+
     fn test_commit_envs() -> HashMap {
         let mut env = checkpoint_author_envs();
         env.insert("GIT_ASKPASS".to_string(), "false".to_string());
@@ -3978,6 +4137,162 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_load_commit_with_type_changed_file(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().expect("failed to create temporary repository");
+        git_init_repo(repo_dir.path());
+        fs::write(repo_dir.path().join("file.txt"), "regular contents\n")
+            .expect("failed to write regular file");
+        git_command(repo_dir.path(), ["add", "file.txt"]);
+        git_command(repo_dir.path(), ["commit", "-m", "initial"]);
+
+        let repository = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .expect("failed to open repository");
+        fs::write(repo_dir.path().join("file.txt"), "target")
+            .expect("failed to write symlink target");
+
+        let symlink_blob = repository
+            .git_binary()
+            .run(&["hash-object", "-w", "file.txt"])
+            .await
+            .expect("failed to write symlink blob");
+        git_command(
+            repo_dir.path(),
+            [
+                OsString::from("update-index"),
+                OsString::from("--cacheinfo"),
+                OsString::from("120000"),
+                OsString::from(symlink_blob),
+                OsString::from("file.txt"),
+            ],
+        );
+        git_command(repo_dir.path(), ["commit", "-m", "type change"]);
+
+        let commit_diff = repository
+            .load_commit("HEAD".to_string(), cx.to_async())
+            .await
+            .expect("failed to load type-changed commit");
+        assert_eq!(commit_diff.files.len(), 1);
+
+        let file = commit_diff
+            .files
+            .first()
+            .expect("type-changed file should be present");
+        assert_eq!(file.path.as_unix_str(), "file.txt");
+        assert_eq!(file.old_text.as_deref(), Some("regular contents\n"));
+        assert_eq!(file.new_text.as_deref(), Some("target"));
+        assert_eq!(file.status(), CommitFileStatus::Modified);
+    }
+
+    #[gpui::test]
+    async fn test_load_commit_with_gitlink_changes(cx: &mut TestAppContext) {
+        const FIRST_SUBMODULE_COMMIT: &str = "1111111111111111111111111111111111111111";
+        const SECOND_SUBMODULE_COMMIT: &str = "2222222222222222222222222222222222222222";
+
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().expect("failed to create temporary repository");
+        git_init_repo(repo_dir.path());
+        fs::write(repo_dir.path().join("README.md"), "parent repository\n")
+            .expect("failed to write regular file");
+        git_command(repo_dir.path(), ["add", "README.md"]);
+        git_command(
+            repo_dir.path(),
+            [
+                "update-index",
+                "--add",
+                "--cacheinfo",
+                crate::commit::GITLINK_MODE,
+                FIRST_SUBMODULE_COMMIT,
+                "modules/example",
+            ],
+        );
+        git_command(repo_dir.path(), ["commit", "-m", "add submodule"]);
+
+        let repository = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .expect("failed to open repository");
+
+        let commit_diff = repository
+            .load_commit("HEAD".to_string(), cx.to_async())
+            .await
+            .expect("failed to load commit that adds a gitlink");
+        assert_eq!(commit_diff.files.len(), 2);
+        let gitlink = commit_diff
+            .files
+            .iter()
+            .find(|file| file.path.as_unix_str() == "modules/example")
+            .expect("gitlink should be present alongside the regular file");
+        assert_eq!(gitlink.status(), CommitFileStatus::Added);
+        assert_eq!(gitlink.old_text, None);
+        assert_eq!(
+            gitlink.new_text.as_deref(),
+            Some("Subproject commit 1111111111111111111111111111111111111111\n")
+        );
+        assert!(!gitlink.is_binary);
+
+        git_command(
+            repo_dir.path(),
+            [
+                "update-index",
+                "--cacheinfo",
+                crate::commit::GITLINK_MODE,
+                SECOND_SUBMODULE_COMMIT,
+                "modules/example",
+            ],
+        );
+        git_command(repo_dir.path(), ["commit", "-m", "update submodule"]);
+
+        let commit_diff = repository
+            .load_commit("HEAD".to_string(), cx.to_async())
+            .await
+            .expect("failed to load commit that updates a gitlink");
+        let [gitlink] = commit_diff.files.as_slice() else {
+            panic!("expected one updated gitlink");
+        };
+        assert_eq!(gitlink.status(), CommitFileStatus::Modified);
+        assert_eq!(
+            gitlink.old_text.as_deref(),
+            Some("Subproject commit 1111111111111111111111111111111111111111\n")
+        );
+        assert_eq!(
+            gitlink.new_text.as_deref(),
+            Some("Subproject commit 2222222222222222222222222222222222222222\n")
+        );
+        assert!(!gitlink.is_binary);
+
+        git_command(repo_dir.path(), ["rm", "--cached", "modules/example"]);
+        git_command(repo_dir.path(), ["commit", "-m", "remove submodule"]);
+
+        let commit_diff = repository
+            .load_commit("HEAD".to_string(), cx.to_async())
+            .await
+            .expect("failed to load commit that deletes a gitlink");
+        let [gitlink] = commit_diff.files.as_slice() else {
+            panic!("expected one deleted gitlink");
+        };
+        assert_eq!(gitlink.status(), CommitFileStatus::Deleted);
+        assert_eq!(
+            gitlink.old_text.as_deref(),
+            Some("Subproject commit 2222222222222222222222222222222222222222\n")
+        );
+        assert_eq!(gitlink.new_text, None);
+        assert!(!gitlink.is_binary);
+    }
+
     #[gpui::test]
     async fn test_check_access(cx: &mut TestAppContext) {
         disable_git_global_config();
@@ -4078,50 +4393,11 @@ mod tests {
         cx.executor().allow_parking();
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let remote_dir = temp_dir.path().join("remote.git");
-        let seed_dir = temp_dir.path().join("seed");
-        let clone_dir = temp_dir.path().join("clone");
-
-        git_command(
-            temp_dir.path(),
-            [
-                OsString::from("init"),
-                OsString::from("--bare"),
-                OsString::from("-b"),
-                OsString::from("main"),
-                remote_dir.as_os_str().into(),
-            ],
-        );
-        git_init_repo(&seed_dir);
-        fs::write(seed_dir.join("file.txt"), "main").unwrap();
-        git_command(&seed_dir, ["add", "file.txt"]);
-        git_command(&seed_dir, ["commit", "-m", "initial"]);
-        git_command(&seed_dir, ["switch", "-c", "feature"]);
-        fs::write(seed_dir.join("feature.txt"), "feature").unwrap();
-        git_command(&seed_dir, ["add", "feature.txt"]);
-        git_command(&seed_dir, ["commit", "-m", "feature"]);
-        git_command(
-            &seed_dir,
-            [
-                OsString::from("remote"),
-                OsString::from("add"),
-                OsString::from("origin"),
-                remote_dir.as_os_str().into(),
-            ],
-        );
-        git_command(&seed_dir, ["push", "-u", "origin", "main"]);
-        git_command(&seed_dir, ["push", "-u", "origin", "feature"]);
-        git_command(
-            temp_dir.path(),
-            [
-                OsString::from("clone"),
-                remote_dir.as_os_str().into(),
-                clone_dir.as_os_str().into(),
-            ],
-        );
+        let (_remote_directory, clone_directory) =
+            clone_remote_repository_with_main_and_feature(temp_dir.path());
 
         let repository = RealGitRepository::new(
-            &clone_dir.join(".git"),
+            &clone_directory.join(".git"),
             None,
             Some("git".into()),
             cx.executor(),
@@ -4184,6 +4460,115 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_change_branch_resolves_remote_head_to_tracking_branch(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let temp_dir = tempfile::tempdir().unwrap();
+        let (_remote_directory, clone_directory) =
+            clone_remote_repository_with_main_and_feature(temp_dir.path());
+
+        let repository = RealGitRepository::new(
+            &clone_directory.join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+        let git = repository.git_binary_in_worktree().unwrap();
+        git.run(&[
+            "symbolic-ref",
+            "refs/remotes/origin/HEAD",
+            "refs/remotes/origin/feature",
+        ])
+        .await
+        .unwrap();
+        assert_eq!(
+            git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
+                .await
+                .unwrap(),
+            "refs/remotes/origin/feature"
+        );
+        assert!(
+            git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"])
+                .await
+                .is_err()
+        );
+
+        repository
+            .change_branch("origin/HEAD".to_string())
+            .await
+            .unwrap();
+
+        let git = repository.git_binary_in_worktree().unwrap();
+        assert_eq!(
+            git.run(&["branch", "--show-current"]).await.unwrap(),
+            "feature"
+        );
+        assert_eq!(
+            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
+                .await
+                .unwrap(),
+            "origin/feature"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_branch_resolves_non_origin_remote_head(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let temp_dir = tempfile::tempdir().unwrap();
+        let (remote_directory, clone_directory) =
+            clone_remote_repository_with_main_and_feature(temp_dir.path());
+
+        git_command(
+            &clone_directory,
+            [
+                OsString::from("remote"),
+                OsString::from("add"),
+                OsString::from("upstream"),
+                remote_directory.as_os_str().into(),
+            ],
+        );
+        git_command(&clone_directory, ["fetch", "upstream"]);
+
+        let repository = RealGitRepository::new(
+            &clone_directory.join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+        let git = repository.git_binary_in_worktree().unwrap();
+        git.run(&[
+            "symbolic-ref",
+            "refs/remotes/upstream/HEAD",
+            "refs/remotes/upstream/main",
+        ])
+        .await
+        .unwrap();
+        git.run(&["checkout", "-b", "scratch"]).await.unwrap();
+
+        repository
+            .change_branch("upstream/HEAD".to_string())
+            .await
+            .unwrap();
+
+        let git = repository.git_binary_in_worktree().unwrap();
+        assert_eq!(
+            git.run(&["branch", "--show-current"]).await.unwrap(),
+            "main"
+        );
+        assert_eq!(
+            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
+                .await
+                .unwrap(),
+            "upstream/main"
+        );
+    }
+
     #[gpui::test]
     fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) {
         disable_git_global_config();
@@ -4274,6 +4659,42 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) {
+        disable_git_global_config();
+
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+
+        git_init_repo(repo_dir.path());
+        fs::write(repo_dir.path().join("file"), "initial").unwrap();
+        git_command(repo_dir.path(), ["add", "file"]);
+        git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]);
+
+        let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"])
+            .parse()
+            .unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        let (request_tx, request_rx) = async_channel::unbounded();
+
+        repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx)
+            .await
+            .unwrap();
+
+        let graph_data = request_rx.recv().await.unwrap();
+        assert_eq!(graph_data.len(), 1);
+        assert_eq!(graph_data[0].sha, commit_sha);
+    }
+
     #[gpui::test]
     async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
         cx.executor().allow_parking();
@@ -4531,6 +4952,184 @@ mod tests {
         // );
     }
 
+    #[cfg(unix)]
+    #[gpui::test]
+    async fn test_commit_runs_git_hooks(cx: &mut TestAppContext) {
+        use std::os::unix::fs::PermissionsExt as _;
+
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+        git_init_repo(repo_dir.path());
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        let hooks_dir = repo_dir.path().join(".git").join("hooks");
+        fs::create_dir_all(&hooks_dir).unwrap();
+        let write_hook = |name: &str, contents: &str| {
+            let path = hooks_dir.join(name);
+            fs::write(&path, contents).unwrap();
+            fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
+        };
+
+        write_hook("pre-commit", "#!/bin/sh\nexit 1\n");
+
+        fs::write(repo_dir.path().join("file"), "one").unwrap();
+        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+
+        // Hooks must not run for untrusted repositories.
+        repo.commit(
+            "Commit in untrusted repo".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(test_commit_envs()),
+        )
+        .await
+        .expect("failing pre-commit hook should be skipped in untrusted repos");
+
+        repo.set_trusted(true);
+
+        fs::write(repo_dir.path().join("file"), "two").unwrap();
+        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+
+        repo.commit(
+            "Commit blocked by hook".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(test_commit_envs()),
+        )
+        .await
+        .expect_err("failing pre-commit hook should abort the commit");
+
+        write_hook("pre-commit", "#!/bin/sh\nexit 0\n");
+        write_hook(
+            "commit-msg",
+            "#!/bin/sh\necho 'rewritten by commit-msg hook' > \"$1\"\n",
+        );
+
+        repo.commit(
+            "Original message".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(test_commit_envs()),
+        )
+        .await
+        .unwrap();
+
+        let message = git_command_output(repo_dir.path(), ["log", "-1", "--pretty=%B"]);
+        assert_eq!(message, "rewritten by commit-msg hook");
+    }
+
+    #[gpui::test]
+    async fn test_load_revisions(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+        git_init_repo(repo_dir.path());
+
+        let file1_path = repo_dir.path().join("file1");
+        let file2_path = repo_dir.path().join("file2");
+        let space_file_path = repo_dir.path().join("file with spaces");
+
+        smol::fs::write(&file1_path, "file1 committed contents")
+            .await
+            .unwrap();
+        smol::fs::write(&file2_path, "file2 committed contents")
+            .await
+            .unwrap();
+        smol::fs::write(&space_file_path, "space file committed contents")
+            .await
+            .unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        // Stage files and commit
+        repo.stage_paths(
+            vec![
+                repo_path("file1"),
+                repo_path("file2"),
+                repo_path("file with spaces"),
+            ],
+            Arc::new(HashMap::default()),
+        )
+        .await
+        .unwrap();
+        repo.commit(
+            "Initial commit".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(test_commit_envs()),
+        )
+        .await
+        .unwrap();
+
+        // Now modify files in index but not yet committed
+        smol::fs::write(&file1_path, "file1 index contents")
+            .await
+            .unwrap();
+        repo.stage_paths(vec![repo_path("file1")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+
+        // Write working tree contents (not indexed, not committed)
+        smol::fs::write(&file1_path, "file1 worktree contents")
+            .await
+            .unwrap();
+
+        // Now test load_revisions
+        let results = repo
+            .load_revisions(
+                [
+                    "HEAD:file1",
+                    ":file1",
+                    "HEAD:file2",
+                    ":file2",
+                    "HEAD:nonexistent",
+                    "HEAD:file with spaces",
+                    "HEAD:nonexistent file with spaces",
+                ]
+                .into_iter()
+                .map(String::from)
+                .collect(),
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(
+            results,
+            vec![
+                Some("file1 committed contents".into()),
+                Some("file1 index contents".into()),
+                Some("file2 committed contents".into()),
+                Some("file2 committed contents".into()), // untouched in index, should match HEAD
+                None,
+                Some("space file committed contents".into()),
+                None,
+            ]
+        );
+    }
+
     #[gpui::test]
     async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
         disable_git_global_config();
diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs
index de1a0c54097738..1a04d02e75344b 100644
--- a/crates/git/src/status.rs
+++ b/crates/git/src/status.rs
@@ -454,7 +454,7 @@ impl FromStr for GitStatus {
                 let status = entry.as_bytes()[0..2].try_into().unwrap();
                 let status = FileStatus::from_bytes(status).log_err()?;
                 // git-status outputs `/`-delimited repo paths, even on Windows.
-                let path = RepoPath::from_rel_path(RelPath::unix(path).log_err()?);
+                let path = RepoPath::from_rel_path(RelPath::from_unix_str(path).log_err()?);
                 Some((path, status))
             })
             .collect::>();
@@ -544,7 +544,7 @@ impl FromStr for TreeDiff {
         let mut fields = s.split('\0');
         let mut parsed = HashMap::default();
         while let Some((status, path)) = fields.next().zip(fields.next()) {
-            let path = RepoPath::from_rel_path(RelPath::unix(path)?);
+            let path = RepoPath::from_rel_path(RelPath::from_unix_str(path)?);
 
             let mut fields = status.split(" ").skip(2);
             let old_sha = fields
diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml
index 4b6fc85041b346..f46146ef8c35ba 100644
--- a/crates/git_ui/Cargo.toml
+++ b/crates/git_ui/Cargo.toml
@@ -23,14 +23,15 @@ askpass.workspace = true
 async-channel.workspace = true
 buffer_diff.workspace = true
 call = { workspace = true, optional = true }
+client.workspace = true
 collections.workspace = true
 component.workspace = true
 db.workspace = true
 editor.workspace = true
 file_icons.workspace = true
 fs.workspace = true
-futures.workspace = true
 futures-lite.workspace = true
+futures.workspace = true
 fuzzy.workspace = true
 fuzzy_nucleo.workspace = true
 git.workspace = true
@@ -50,8 +51,8 @@ prompt_store.workspace = true
 proto.workspace = true
 rand.workspace = true
 release_channel.workspace = true
-remote_connection.workspace = true
 remote.workspace = true
+remote_connection.workspace = true
 schemars.workspace = true
 search.workspace = true
 serde.workspace = true
@@ -67,6 +68,7 @@ theme.workspace = true
 theme_settings.workspace = true
 time.workspace = true
 time_format.workspace = true
+tracing.workspace = true
 ui.workspace = true
 ui_input.workspace = true
 util.workspace = true
@@ -75,7 +77,6 @@ workspace.workspace = true
 zed_actions.workspace = true
 zeroize.workspace = true
 ztracing.workspace = true
-tracing.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true
@@ -91,12 +92,12 @@ indoc.workspace = true
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 rand.workspace = true
+remote_connection = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 task.workspace = true
 unindent.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
 zlog.workspace = true
-remote_connection = { workspace = true, features = ["test-support"] }
 
 [package.metadata.cargo-machete]
 ignored = ["tracing"]
diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs
index 90d5948de5113c..07097e21092b04 100644
--- a/crates/git_ui/src/blame_ui.rs
+++ b/crates/git_ui/src/blame_ui.rs
@@ -1,11 +1,11 @@
 use crate::{
-    commit_tooltip::{CommitAvatar, CommitTooltip},
+    commit_tooltip::{CommitAvatar, CommitTooltip, commit_tag_chips},
     commit_view::CommitView,
 };
 use editor::{BlameRenderer, Editor, hover_markdown_style};
 use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
 use gpui::{
-    ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle,
+    ClipboardItem, Entity, Hsla, MouseButton, Pixels, Rems, ScrollHandle, Subscription, TextStyle,
     TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
 };
 use markdown::{Markdown, MarkdownElement};
@@ -20,6 +20,9 @@ use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container};
 use workspace::Workspace;
 
 const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
+const GIT_BLAME_GUTTER_MARGIN: Rems = rems(0.5);
+const GIT_BLAME_GUTTER_GAP: Rems = rems(0.5);
+const GIT_BLAME_AVATAR_SIZE: Rems = rems(1.);
 
 pub struct GitBlameRenderer;
 
@@ -126,11 +129,25 @@ impl BlameRenderer for GitBlameRenderer {
         GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED
     }
 
+    fn blame_entry_non_text_width(&self, window: &Window, cx: &App) -> Pixels {
+        let show_avatar = ProjectSettings::get_global(cx).git.blame.show_avatar;
+        let gap_count = if show_avatar { 3. } else { 2. };
+        let width = GIT_BLAME_GUTTER_MARGIN.to_pixels(window.rem_size())
+            + GIT_BLAME_GUTTER_GAP.to_pixels(window.rem_size()) * gap_count;
+
+        if show_avatar {
+            width + CommitAvatar::rendered_size(GIT_BLAME_AVATAR_SIZE, window)
+        } else {
+            width
+        }
+    }
+
     fn render_blame_entry(
         &self,
         style: &TextStyle,
         blame_entry: BlameEntry,
         details: Option,
+        tag_names: Vec,
         repository: Entity,
         workspace: WeakEntity,
         editor: Entity,
@@ -159,6 +176,7 @@ impl BlameRenderer for GitBlameRenderer {
                     author_email,
                     details.as_ref().and_then(|it| it.remote.as_ref()),
                 )
+                .size(GIT_BLAME_AVATAR_SIZE)
                 .render(window, cx),
             )
         } else {
@@ -226,6 +244,7 @@ impl BlameRenderer for GitBlameRenderer {
                                     CommitTooltip::blame_entry(
                                         &blame_entry,
                                         details.clone(),
+                                        tag_names.clone(),
                                         repository.clone(),
                                         workspace.clone(),
                                         cx,
@@ -266,6 +285,7 @@ impl BlameRenderer for GitBlameRenderer {
         blame: BlameEntry,
         scroll_handle: ScrollHandle,
         details: Option,
+        tag_names: Vec,
         markdown: Entity,
         repository: Entity,
         workspace: WeakEntity,
@@ -402,12 +422,16 @@ impl BlameRenderer for GitBlameRenderer {
                                     .w_full()
                                     .justify_between()
                                     .pt_1()
+                                    .gap_1()
+                                    .flex_wrap()
                                     .border_t_1()
                                     .border_color(cx.theme().colors().border_variant)
                                     .child(absolute_timestamp)
                                     .child(
                                         h_flex()
                                             .gap_1()
+                                            .min_w_0()
+                                            .children(commit_tag_chips(&tag_names))
                                             .when_some(pull_request, |this, pr| {
                                                 this.child(
                                                     Button::new(
diff --git a/crates/git_ui/src/branch_diff.rs b/crates/git_ui/src/branch_diff.rs
new file mode 100644
index 00000000000000..068d8e9af19734
--- /dev/null
+++ b/crates/git_ui/src/branch_diff.rs
@@ -0,0 +1,1199 @@
+use crate::{
+    branch_picker,
+    diff_multibuffer::DiffMultibuffer,
+    project_diff::{
+        self, CompareWithBranch, DeployBranchDiff, ReviewDiff, render_send_review_to_agent_button,
+    },
+};
+use agent_settings::AgentSettings;
+use anyhow::{Context as _, Result, anyhow};
+use editor::{
+    Addon, Editor, EditorEvent, RestoreOnlyDiffHunkDelegate, SplittableEditor,
+    actions::SendReviewToAgent,
+};
+use git::{repository::DiffType, status::FileStatus};
+use gpui::{
+    Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render,
+    SharedString, Subscription, Task, WeakEntity,
+};
+use language::{BufferId, Capability};
+use project::{
+    Project, ProjectPath,
+    git_store::{
+        Repository,
+        diff_buffer_list::{self, DiffBase},
+    },
+};
+use settings::Settings;
+use std::{
+    any::{Any, TypeId},
+    sync::Arc,
+};
+use ui::{DiffStat, Divider, PopoverMenu, Tooltip, prelude::*};
+use workspace::{
+    ItemHandle, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
+    ToolbarItemView, Workspace,
+    item::{Item, ItemEvent, SaveOptions, TabContentParams},
+    notifications::NotifyTaskExt,
+    searchable::SearchableItemHandle,
+};
+use zed_actions::agent::ReviewBranchDiff;
+
+/// The workspace item for a branch (merge-base) diff: "Changes since {branch}".
+/// It wraps a single [`DiffMultibuffer`] over [`DiffBase::Merge`] and delegates
+/// the [`Item`] surface to it. The merge base can be changed in place via the
+/// [`BranchDiffToolbar`]'s branch picker, which reloads without reconfiguring
+/// the editor (the merge styling is identical for every base ref).
+pub struct BranchDiff {
+    diff: Entity,
+    project: Entity,
+    workspace: WeakEntity,
+    _diff_event_subscription: Subscription,
+}
+
+struct BranchDiffAddon {
+    branch_diff: Entity,
+}
+
+impl Addon for BranchDiffAddon {
+    fn to_any(&self) -> &dyn std::any::Any {
+        self
+    }
+
+    fn override_status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option {
+        self.branch_diff
+            .read(cx)
+            .status_for_buffer_id(buffer_id, cx)
+    }
+}
+
+impl BranchDiff {
+    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) {
+        workspace.register_action(Self::deploy_branch_diff);
+        workspace.register_action(Self::compare_with_branch);
+        workspace::register_serializable_item::(cx);
+    }
+
+    fn deploy_branch_diff(
+        workspace: &mut Workspace,
+        _: &DeployBranchDiff,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        telemetry::event!("Git Branch Diff Opened");
+        let project = workspace.project().clone();
+        let Some(intended_repo) = project.read(cx).active_repository(cx) else {
+            let workspace = cx.entity().downgrade();
+            window
+                .spawn(cx, async |_cx| {
+                    let result: Result<()> = Err(anyhow!("No active repository"));
+                    result
+                })
+                .detach_and_notify_err(workspace, window, cx);
+            return;
+        };
+
+        let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true));
+        let workspace = cx.entity();
+        let workspace_weak = workspace.downgrade();
+        window
+            .spawn(cx, async move |cx| {
+                let base_ref = default_branch
+                    .await??
+                    .context("Could not determine default branch")?;
+
+                workspace.update_in(cx, |workspace, window, cx| {
+                    Self::deploy_branch_diff_with_base_ref(
+                        workspace,
+                        project,
+                        intended_repo,
+                        base_ref,
+                        window,
+                        cx,
+                    );
+                })?;
+
+                anyhow::Ok(())
+            })
+            .detach_and_notify_err(workspace_weak, window, cx);
+    }
+
+    fn compare_with_branch(
+        workspace: &mut Workspace,
+        _: &CompareWithBranch,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let project = workspace.project().clone();
+        let Some(repository) = project.read(cx).active_repository(cx) else {
+            let workspace = cx.entity().downgrade();
+            window
+                .spawn(cx, async |_cx| {
+                    let result: Result<()> = Err(anyhow!("No active repository"));
+                    result
+                })
+                .detach_and_notify_err(workspace, window, cx);
+            return;
+        };
+        let selected_branch = workspace.active_item_as::(cx).and_then(|item| {
+            match item.read(cx).diff_base(cx) {
+                DiffBase::Merge { base_ref } => Some(base_ref.clone()),
+                DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
+            }
+        });
+        let workspace_handle = workspace.weak_handle();
+        let on_select = Arc::new({
+            let repository = repository.clone();
+            let workspace = workspace_handle.clone();
+            move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| {
+                let base_ref: SharedString = branch.name().to_owned().into();
+                workspace
+                    .update(cx, |workspace, cx| {
+                        Self::deploy_branch_diff_with_base_ref(
+                            workspace,
+                            project.clone(),
+                            repository.clone(),
+                            base_ref,
+                            window,
+                            cx,
+                        );
+                    })
+                    .ok();
+            }
+        });
+
+        workspace.toggle_modal(window, cx, |window, cx| {
+            branch_picker::select_modal(
+                workspace_handle,
+                Some(repository),
+                selected_branch,
+                on_select,
+                window,
+                cx,
+            )
+        });
+    }
+
+    fn deploy_branch_diff_with_base_ref(
+        workspace: &mut Workspace,
+        project: Entity,
+        intended_repo: Entity,
+        base_ref: SharedString,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let existing = workspace.items_of_type::(cx).find(|item| {
+            let item = item.read(cx);
+            matches!(
+                item.diff_base(cx),
+                DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref
+            )
+        });
+        if let Some(existing) = existing {
+            workspace.activate_item(&existing, true, true, window, cx);
+
+            let needs_switch = existing.read(cx).repo(cx).map_or(true, |current| {
+                current.read(cx).id != intended_repo.read(cx).id
+            });
+
+            if needs_switch {
+                existing.update(cx, |branch_diff, cx| {
+                    branch_diff.set_repo(Some(intended_repo), cx);
+                });
+            }
+
+            return;
+        }
+
+        let workspace = cx.entity();
+        let workspace_weak = workspace.downgrade();
+        window
+            .spawn(cx, async move |cx| {
+                let this = cx
+                    .update(|window, cx| {
+                        Self::new_with_branch_base(
+                            project,
+                            workspace.clone(),
+                            base_ref,
+                            intended_repo,
+                            window,
+                            cx,
+                        )
+                    })?
+                    .await?;
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
+                    })
+                    .ok();
+                anyhow::Ok(())
+            })
+            .detach_and_notify_err(workspace_weak, window, cx);
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn new_with_default_branch(
+        project: Entity,
+        workspace: Entity,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
+            return Task::ready(Err(anyhow!("No active repository")));
+        };
+        let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
+        window.spawn(cx, async move |cx| {
+            let base_ref = main_branch
+                .await??
+                .context("Could not determine default branch")?;
+            cx.update(|window, cx| {
+                cx.new(|cx| {
+                    Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx)
+                })
+            })
+        })
+    }
+
+    pub(crate) fn new_with_branch_base(
+        project: Entity,
+        workspace: Entity,
+        base_ref: SharedString,
+        repo: Entity,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        window.spawn(cx, async move |cx| {
+            cx.update(|window, cx| {
+                cx.new(|cx| {
+                    Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx)
+                })
+            })
+        })
+    }
+
+    pub(crate) fn new_with_base_ref(
+        project: Entity,
+        workspace: Entity,
+        base_ref: SharedString,
+        repo: Option>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Self {
+        let branch_diff = cx.new(|cx| {
+            let mut branch_diff = diff_buffer_list::DiffBufferList::new(
+                DiffBase::Merge { base_ref },
+                project.clone(),
+                window,
+                cx,
+            );
+            if repo.is_some() {
+                branch_diff.set_repo(repo, cx);
+            }
+            branch_diff
+        });
+        let branch_diff_for_addon = branch_diff.clone();
+        let diff = cx.new(|cx| {
+            DiffMultibuffer::new(
+                branch_diff,
+                Capability::ReadWrite,
+                "No changes",
+                move |editor, cx| {
+                    editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx);
+                    editor.rhs_editor().update(cx, move |rhs_editor, _cx| {
+                        rhs_editor.set_read_only(false);
+                        rhs_editor.register_addon(BranchDiffAddon {
+                            branch_diff: branch_diff_for_addon,
+                        });
+                    });
+                },
+                project.clone(),
+                workspace.clone(),
+                window,
+                cx,
+            )
+        });
+        Self::from_diff(diff, project, workspace, cx)
+    }
+
+    fn from_diff(
+        diff: Entity,
+        project: Entity,
+        workspace: Entity,
+        cx: &mut Context,
+    ) -> Self {
+        let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| {
+            cx.emit(event.clone())
+        });
+        Self {
+            diff,
+            project,
+            workspace: workspace.downgrade(),
+            _diff_event_subscription: diff_event_subscription,
+        }
+    }
+
+    pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
+        self.diff.read(cx).diff_base(cx)
+    }
+
+    pub(crate) fn repo(&self, cx: &App) -> Option> {
+        self.diff.read(cx).repo(cx)
+    }
+
+    pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) {
+        self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx));
+    }
+
+    fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context) {
+        self.diff.update(cx, |diff, cx| {
+            diff.branch_diff().update(cx, |branch_diff, cx| {
+                branch_diff.set_diff_base(DiffBase::Merge { base_ref }, cx);
+            });
+        });
+    }
+
+    fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) {
+        let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
+            return;
+        };
+        let Some(repo) = self.repo(cx) else {
+            return;
+        };
+
+        let diff_receiver = repo.update(cx, |repo, cx| {
+            repo.diff(
+                DiffType::MergeBase {
+                    base_ref: base_ref.clone(),
+                },
+                cx,
+            )
+        });
+
+        let workspace = self.workspace.clone();
+        window
+            .spawn(cx, {
+                let workspace = workspace.clone();
+                async move |cx| {
+                    let diff_text = diff_receiver.await??;
+
+                    if let Some(workspace) = workspace.upgrade() {
+                        workspace.update_in(cx, |_workspace, window, cx| {
+                            window.dispatch_action(
+                                ReviewBranchDiff {
+                                    diff_text: diff_text.into(),
+                                    base_ref,
+                                }
+                                .boxed_clone(),
+                                cx,
+                            );
+                        })?;
+                    }
+
+                    anyhow::Ok(())
+                }
+            })
+            .detach_and_notify_err(workspace, window, cx);
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn editor(&self, cx: &App) -> Entity {
+        self.diff.read(cx).editor().clone()
+    }
+}
+
+impl EventEmitter for BranchDiff {}
+
+impl Focusable for BranchDiff {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.diff.read(cx).focus_handle(cx)
+    }
+}
+
+impl Item for BranchDiff {
+    type Event = EditorEvent;
+
+    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option {
+        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
+    }
+
+    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context) {
+        self.diff
+            .update(cx, |diff, cx| diff.deactivated(window, cx));
+    }
+
+    fn navigate(
+        &mut self,
+        data: Arc,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> bool {
+        self.diff
+            .update(cx, |diff, cx| diff.navigate(data, window, cx))
+    }
+
+    fn tab_tooltip_text(&self, cx: &App) -> Option {
+        Some(self.tab_content_text(0, cx))
+    }
+
+    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
+        Label::new(self.tab_content_text(0, cx))
+            .color(if params.selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
+        match self.diff_base(cx) {
+            DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
+            DiffBase::Head | DiffBase::Index | DiffBase::Staged => "Changes".into(),
+        }
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Branch Diff Opened")
+    }
+
+    fn as_searchable(&self, _: &Entity, cx: &App) -> Option> {
+        Some(Box::new(self.diff.read(cx).editor().clone()))
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.diff.read(cx).for_each_project_item(cx, f);
+    }
+
+    fn active_project_path(&self, cx: &App) -> Option {
+        self.diff.read(cx).active_project_path(cx)
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff
+            .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
+    }
+
+    fn can_split(&self) -> bool {
+        true
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task>>
+    where
+        Self: Sized,
+    {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return Task::ready(None);
+        };
+        let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
+            return Task::ready(None);
+        };
+        let repo = self.repo(cx);
+        let project = self.project.clone();
+        Task::ready(Some(cx.new(|cx| {
+            Self::new_with_base_ref(project, workspace, base_ref, repo, window, cx)
+        })))
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.diff.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.diff.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _cx: &App) -> bool {
+        true
+    }
+
+    fn save(
+        &mut self,
+        options: SaveOptions,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.diff
+            .update(cx, |diff, cx| diff.save(options, project, window, cx))
+    }
+
+    fn save_as(
+        &mut self,
+        _: Entity,
+        _: ProjectPath,
+        _: &mut Window,
+        _: &mut Context,
+    ) -> Task> {
+        unreachable!()
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.diff
+            .update(cx, |diff, cx| diff.reload(project, window, cx))
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity,
+        cx: &'a App,
+    ) -> Option {
+        if type_id == TypeId::of::() {
+            Some(self_handle.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(
+                self.diff
+                    .read(cx)
+                    .editor()
+                    .read(cx)
+                    .rhs_editor()
+                    .clone()
+                    .into(),
+            )
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).editor().clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).branch_diff().clone().into())
+        } else {
+            None
+        }
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff.update(cx, |diff, cx| {
+            diff.added_to_workspace(workspace, window, cx)
+        });
+    }
+}
+
+impl Render for BranchDiff {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        div()
+            .size_full()
+            .on_action(cx.listener(Self::review_diff))
+            .child(self.diff.clone())
+    }
+}
+
+impl SerializableItem for BranchDiff {
+    fn serialized_item_kind() -> &'static str {
+        "BranchDiff"
+    }
+
+    fn cleanup(
+        _: workspace::WorkspaceId,
+        _: Vec,
+        _: &mut Window,
+        _: &mut App,
+    ) -> Task> {
+        Task::ready(Ok(()))
+    }
+
+    fn deserialize(
+        project: Entity,
+        workspace: WeakEntity,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        let db = project_diff::persistence::ProjectDiffDb::global(cx);
+        window.spawn(cx, async move |cx| {
+            let diff_base = db.get_project_diff_base(item_id, workspace_id)?;
+            let DiffBase::Merge { base_ref } = diff_base else {
+                anyhow::bail!("expected a merge base for a branch diff");
+            };
+            let workspace = workspace.upgrade().context("workspace gone")?;
+            cx.update(|window, cx| {
+                cx.new(|cx| Self::new_with_base_ref(project, workspace, base_ref, None, window, cx))
+            })
+        })
+    }
+
+    fn serialize(
+        &mut self,
+        workspace: &mut Workspace,
+        item_id: workspace::ItemId,
+        _closing: bool,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) -> Option>> {
+        let workspace_id = workspace.database_id()?;
+        let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
+            return None;
+        };
+        let diff_base = DiffBase::Merge { base_ref };
+        let db = project_diff::persistence::ProjectDiffDb::global(cx);
+        Some(cx.background_spawn(async move {
+            db.save_project_diff_base(item_id, workspace_id, diff_base)
+                .await
+        }))
+    }
+
+    fn should_serialize(&self, _: &Self::Event) -> bool {
+        false
+    }
+}
+
+pub struct BranchDiffToolbar {
+    branch_diff: Option>,
+}
+
+impl BranchDiffToolbar {
+    pub fn new(_cx: &mut Context) -> Self {
+        Self { branch_diff: None }
+    }
+
+    fn branch_diff(&self, _: &App) -> Option> {
+        self.branch_diff.as_ref()?.upgrade()
+    }
+
+    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) {
+        if let Some(branch_diff) = self.branch_diff(cx) {
+            branch_diff.focus_handle(cx).focus(window, cx);
+        }
+        let action = action.boxed_clone();
+        cx.defer(move |cx| {
+            cx.dispatch_action(action.as_ref());
+        })
+    }
+}
+
+impl EventEmitter for BranchDiffToolbar {}
+
+impl ToolbarItemView for BranchDiffToolbar {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _: &mut Window,
+        cx: &mut Context,
+    ) -> ToolbarItemLocation {
+        self.branch_diff = active_pane_item
+            .and_then(|item| item.act_as::(cx))
+            .map(|entity| entity.downgrade());
+        if self.branch_diff.is_some() {
+            ToolbarItemLocation::PrimaryRight
+        } else {
+            ToolbarItemLocation::Hidden
+        }
+    }
+
+    fn pane_focus_update(
+        &mut self,
+        _pane_focused: bool,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+}
+
+impl Render for BranchDiffToolbar {
+    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+        let Some(branch_diff) = self.branch_diff(cx) else {
+            return div();
+        };
+        let focus_handle = branch_diff.focus_handle(cx);
+        let review_count = branch_diff
+            .read(cx)
+            .diff
+            .read(cx)
+            .total_review_comment_count();
+        let (additions, deletions) = branch_diff
+            .read(cx)
+            .diff
+            .read(cx)
+            .calculate_changed_lines(cx);
+        let diff_base = branch_diff.read(cx).diff_base(cx).clone();
+        let DiffBase::Merge { base_ref } = diff_base else {
+            return div();
+        };
+        let selected_base_ref = base_ref.clone();
+        let base_ref_label = format!("Base: {base_ref}");
+        let repository = branch_diff.read(cx).repo(cx);
+        let workspace = branch_diff.read(cx).workspace.clone();
+        let view_for_picker = branch_diff.downgrade();
+
+        let is_multibuffer_empty = branch_diff
+            .read(cx)
+            .diff
+            .read(cx)
+            .multibuffer()
+            .read(cx)
+            .is_empty();
+        let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
+
+        let show_review_button = !is_multibuffer_empty && is_ai_enabled;
+
+        h_flex()
+            .my_neg_1()
+            .py_1()
+            .gap_1p5()
+            .flex_wrap()
+            .justify_between()
+            .when(!is_multibuffer_empty, |this| {
+                this.child(DiffStat::new(
+                    "branch-diff-stat",
+                    additions as usize,
+                    deletions as usize,
+                ))
+            })
+            .child(Divider::vertical().ml_1())
+            .child(
+                PopoverMenu::new("branch-diff-base-branch-picker")
+                    .menu(move |window, cx| {
+                        let view_for_picker = view_for_picker.clone();
+                        let on_select = Arc::new(
+                            move |branch: git::repository::Branch,
+                                  _window: &mut Window,
+                                  cx: &mut App| {
+                                let base_ref: SharedString = branch.name().to_owned().into();
+                                view_for_picker
+                                    .update(cx, |branch_diff, cx| {
+                                        branch_diff.set_merge_base(base_ref, cx);
+                                        cx.notify();
+                                    })
+                                    .ok();
+                            },
+                        );
+
+                        Some(branch_picker::select_popover(
+                            workspace.clone(),
+                            repository.clone(),
+                            Some(selected_base_ref.clone()),
+                            on_select,
+                            window,
+                            cx,
+                        ))
+                    })
+                    .trigger_with_tooltip(
+                        Button::new("branch-diff-base-branch", base_ref_label).end_icon(
+                            Icon::new(IconName::ChevronDown)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        ),
+                        Tooltip::text("Select Base Branch"),
+                    ),
+            )
+            .when(show_review_button, |this| {
+                let focus_handle = focus_handle.clone();
+                this.child(Divider::vertical()).child(
+                    Button::new("review-diff", "Review Diff")
+                        .start_icon(
+                            Icon::new(IconName::ZedAssistant)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .tooltip(move |_, cx| {
+                            Tooltip::with_meta_in(
+                                "Review Diff",
+                                Some(&ReviewDiff),
+                                "Send this diff for your last agent to review.",
+                                &focus_handle,
+                                cx,
+                            )
+                        })
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.dispatch_action(&ReviewDiff, window, cx);
+                        })),
+                )
+            })
+            .when(review_count > 0, |this| {
+                this.child(Divider::vertical()).child(
+                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
+                        cx.listener(|this, _, window, cx| {
+                            this.dispatch_action(&SendReviewToAgent, window, cx)
+                        }),
+                    ),
+                )
+            })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use anyhow::anyhow;
+    use collections::HashMap;
+    use editor::test::editor_test_context::assert_state_with_diff;
+    use git::status::{FileStatus, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
+    use gpui::TestAppContext;
+    use project::FakeFs;
+    use serde_json::json;
+    use settings::{DiffViewStyle, SettingsStore};
+    use std::path::Path;
+    use std::sync::Arc;
+    use unindent::Unindent as _;
+    use util::{
+        path,
+        rel_path::{RelPath, rel_path},
+    };
+    use workspace::MultiWorkspace;
+
+    use super::*;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let store = SettingsStore::test(cx);
+            cx.set_global(store);
+            cx.update_global::(|store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
+                });
+            });
+            theme_settings::init(theme::LoadThemes::JustBase, cx);
+            editor::init(cx);
+            crate::init(cx);
+        });
+    }
+
+    #[gpui::test(iterations = 50)]
+    async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        cx.update(|cx| {
+            cx.update_global::(|store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.editor.diff_view_style = Some(DiffViewStyle::Split);
+                });
+            });
+        });
+
+        let build_conflict_text: fn(usize) -> String = |tag: usize| {
+            let mut lines = (0..80)
+                .map(|line_index| format!("line {line_index}"))
+                .collect::>();
+            for offset in [5usize, 20, 37, 61] {
+                lines[offset] = format!("base-{tag}-line-{offset}");
+            }
+            format!("{}\n", lines.join("\n"))
+        };
+        let initial_conflict_text = build_conflict_text(0);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "helper.txt": "same\n",
+                "conflict.txt": initial_conflict_text,
+            }),
+        )
+        .await;
+        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
+            state
+                .refs
+                .insert("MERGE_HEAD".into(), "conflict-head".into());
+        })
+        .unwrap();
+        fs.set_status_for_repo(
+            path!("/project/.git").as_ref(),
+            &[(
+                "conflict.txt",
+                FileStatus::Unmerged(UnmergedStatus {
+                    first_head: UnmergedStatusCode::Updated,
+                    second_head: UnmergedStatusCode::Updated,
+                }),
+            )],
+        );
+        fs.set_merge_base_content_for_repo(
+            path!("/project/.git").as_ref(),
+            &[
+                ("conflict.txt", build_conflict_text(1)),
+                ("helper.txt", "same\n".to_string()),
+            ],
+        );
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        let _branch_diff = cx
+            .update(|window, cx| {
+                BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
+        let buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/conflict.txt"), cx)
+            })
+            .await
+            .unwrap();
+        buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
+        assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
+        cx.run_until_parked();
+
+        cx.update(|window, cx| {
+            let fs = fs.clone();
+            window
+                .spawn(cx, async move |cx| {
+                    cx.background_executor().simulate_random_delay().await;
+                    fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
+                        state.refs.insert("HEAD".into(), "head-1".into());
+                        state.refs.remove("MERGE_HEAD");
+                    })
+                    .unwrap();
+                    fs.set_status_for_repo(
+                        path!("/project/.git").as_ref(),
+                        &[
+                            (
+                                "conflict.txt",
+                                FileStatus::Tracked(TrackedStatus {
+                                    index_status: git::status::StatusCode::Modified,
+                                    worktree_status: git::status::StatusCode::Modified,
+                                }),
+                            ),
+                            (
+                                "helper.txt",
+                                FileStatus::Tracked(TrackedStatus {
+                                    index_status: git::status::StatusCode::Modified,
+                                    worktree_status: git::status::StatusCode::Modified,
+                                }),
+                            ),
+                        ],
+                    );
+                    // FakeFs assigns deterministic OIDs by entry position; flipping order churns
+                    // conflict diff identity without reaching into view internals.
+                    fs.set_merge_base_content_for_repo(
+                        path!("/project/.git").as_ref(),
+                        &[
+                            ("helper.txt", "helper-base\n".to_string()),
+                            ("conflict.txt", build_conflict_text(2)),
+                        ],
+                    );
+                })
+                .detach();
+        });
+
+        cx.update(|window, cx| {
+            let buffer = buffer.clone();
+            window
+                .spawn(cx, async move |cx| {
+                    cx.background_executor().simulate_random_delay().await;
+                    for edit_index in 0..10 {
+                        if edit_index > 0 {
+                            cx.background_executor().simulate_random_delay().await;
+                        }
+                        buffer.update(cx, |buffer, cx| {
+                            let len = buffer.len();
+                            if edit_index % 2 == 0 {
+                                buffer.edit(
+                                    [(0..0, format!("status-burst-head-{edit_index}\n"))],
+                                    None,
+                                    cx,
+                                );
+                            } else {
+                                buffer.edit(
+                                    [(len..len, format!("status-burst-tail-{edit_index}\n"))],
+                                    None,
+                                    cx,
+                                );
+                            }
+                        });
+                    }
+                })
+                .detach();
+        });
+
+        cx.run_until_parked();
+    }
+
+    #[gpui::test]
+    async fn test_branch_diff(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "a.txt": "C",
+                "b.txt": "new",
+                "c.txt": "in-merge-base-and-work-tree",
+                "d.txt": "created-in-head",
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        let diff = cx
+            .update(|window, cx| {
+                BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
+        fs.set_head_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
+            "sha",
+        );
+        fs.set_merge_base_content_for_repo(
+            Path::new(path!("/project/.git")),
+            &[
+                ("a.txt", "A".into()),
+                ("c.txt", "in-merge-base-and-work-tree".into()),
+            ],
+        );
+        cx.run_until_parked();
+
+        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
+
+        assert_state_with_diff(
+            &editor,
+            cx,
+            &"
+                - A
+                + ˇC
+                + new
+                + created-in-head"
+                .unindent(),
+        );
+
+        let statuses: HashMap, Option> =
+            editor.update(cx, |editor, cx| {
+                editor
+                    .buffer()
+                    .read(cx)
+                    .all_buffers()
+                    .iter()
+                    .map(|buffer| {
+                        (
+                            buffer.read(cx).file().unwrap().path().clone(),
+                            editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
+                        )
+                    })
+                    .collect()
+            });
+
+        assert_eq!(
+            statuses,
+            HashMap::from_iter([
+                (
+                    rel_path("a.txt").into_arc(),
+                    Some(FileStatus::Tracked(TrackedStatus {
+                        index_status: git::status::StatusCode::Modified,
+                        worktree_status: git::status::StatusCode::Modified
+                    }))
+                ),
+                (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
+                (
+                    rel_path("d.txt").into_arc(),
+                    Some(FileStatus::Tracked(TrackedStatus {
+                        index_status: git::status::StatusCode::Added,
+                        worktree_status: git::status::StatusCode::Added
+                    }))
+                )
+            ])
+        );
+    }
+
+    #[gpui::test]
+    async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "a.txt": "changed",
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+        let target_branch_diff = cx
+            .update(|window, cx| {
+                let Some(repository) = project.read(cx).active_repository(cx) else {
+                    return Task::ready(Err(anyhow!("No active repository")));
+                };
+                BranchDiff::new_with_branch_base(
+                    project.clone(),
+                    workspace.clone(),
+                    "topic".into(),
+                    repository,
+                    window,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(
+                Box::new(target_branch_diff.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(DeployBranchDiff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| {
+            let active_item = workspace.active_item_as::(cx).unwrap();
+            let active_base_ref = match active_item.read(cx).diff_base(cx) {
+                DiffBase::Merge { base_ref } => base_ref.to_string(),
+                DiffBase::Head | DiffBase::Index | DiffBase::Staged => {
+                    panic!("expected active item to be a branch diff")
+                }
+            };
+            let base_refs = workspace
+                .items_of_type::(cx)
+                .filter_map(|item| match item.read(cx).diff_base(cx) {
+                    DiffBase::Merge { base_ref } => Some(base_ref.to_string()),
+                    DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
+                })
+                .collect::>();
+            (active_base_ref, base_refs)
+        });
+        base_refs.sort();
+
+        assert_eq!(active_base_ref, "origin/main");
+        assert_eq!(base_refs, vec!["origin/main", "topic"]);
+    }
+}
diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs
index 1cbfcca4364d64..a953eb9c61f649 100644
--- a/crates/git_ui/src/branch_picker.rs
+++ b/crates/git_ui/src/branch_picker.rs
@@ -2,11 +2,12 @@ use anyhow::Context as _;
 use editor::Editor;
 use fuzzy_nucleo::StringMatchCandidate;
 
-use collections::HashSet;
+use collections::{HashMap, HashSet};
 use git::repository::{Branch, delete_branch_flag};
+use git::{GitHostingProviderRegistry, parse_git_remote_url};
 use gpui::http_client::Url;
 use gpui::{
-    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
     InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
     Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
 };
@@ -14,11 +15,12 @@ use picker::{Picker, PickerDelegate, PickerEditorPosition};
 use project::git_store::{Repository, RepositoryEvent};
 use project::project_settings::ProjectSettings;
 use settings::Settings;
+
 use std::sync::Arc;
 use time::OffsetDateTime;
 use ui::{
-    Banner, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Severity, Tooltip,
-    prelude::*,
+    Banner, ContextMenu, Divider, HighlightedLabel, Indicator, KeyBinding, ListItem,
+    ListItemSpacing, ListSubHeader, PopoverMenu, PopoverMenuHandle, Severity, Tooltip, prelude::*,
 };
 use ui_input::ErasedEditor;
 use util::ResultExt;
@@ -34,8 +36,16 @@ actions!(
         DeleteBranch,
         /// Force deletes the selected git branch or remote.
         ForceDeleteBranch,
-        /// Filter the list of remotes
-        FilterRemotes
+        /// Show all branches.
+        ShowAllBranches,
+        /// Show only local branches.
+        ShowLocalBranches,
+        /// Show only remote branches.
+        ShowRemoteBranches,
+        /// Cycle through branch filters.
+        CycleBranchFilter,
+        /// Toggles the branch filter menu.
+        ToggleFilterMenu
     ]
 );
 
@@ -181,8 +191,10 @@ impl BranchList {
     ) -> Self {
         let mut this = Self::new_inner(workspace, repository, style, width, false, window, cx);
         this._subscriptions
-            .push(cx.subscribe(&this.picker, |_, _, _, cx| {
-                cx.emit(DismissEvent);
+            .push(cx.subscribe(&this.picker, |this, _, _, cx| {
+                if !this.branch_filter_menu_open(cx) {
+                    cx.emit(DismissEvent);
+                }
             }));
         this
     }
@@ -232,8 +244,10 @@ impl BranchList {
             cx,
         );
         this._subscriptions
-            .push(cx.subscribe(&this.picker, |_, _, _, cx| {
-                cx.emit(DismissEvent);
+            .push(cx.subscribe(&this.picker, |this, _, _, cx| {
+                if !this.branch_filter_menu_open(cx) {
+                    cx.emit(DismissEvent);
+                }
             }));
         this
     }
@@ -253,7 +267,7 @@ impl BranchList {
             .map(|repo| {
                 process_branches(
                     &repo.read(cx).branch_list,
-                    branch_selection_behavior.selected_branch(),
+                    !branch_selection_behavior.is_select_only(),
                 )
             })
             .unwrap_or_default();
@@ -264,6 +278,9 @@ impl BranchList {
         let default_branch_request = repository.clone().map(|repository| {
             repository.update(cx, |repository, _| repository.default_branch(false))
         });
+        let remote_urls_request = repository
+            .clone()
+            .map(|repository| repository.update(cx, |repository, _| repository.remote_urls()));
 
         let mut delegate = BranchListDelegate::new(
             workspace,
@@ -276,7 +293,7 @@ impl BranchList {
         delegate.branch_list_error = branch_list_error;
 
         let picker = cx.new(|cx| {
-            Picker::uniform_list(delegate, window, cx)
+            Picker::list(delegate, window, cx)
                 .initial_width(width)
                 .show_scrollbar(true)
                 .when(embedded, |picker| picker.embedded())
@@ -307,7 +324,7 @@ impl BranchList {
                                 .and_then(|entry| entry.as_branch().map(|b| b.ref_name.clone()));
                             picker.delegate.all_branches = process_branches(
                                 &branch_list,
-                                picker.delegate.branch_selection_behavior.selected_branch(),
+                                !picker.delegate.branch_selection_behavior.is_select_only(),
                             );
                             picker.delegate.branch_list_error = branch_list_error;
                             picker.refresh(window, cx);
@@ -337,6 +354,21 @@ impl BranchList {
         })
         .detach_and_log_err(cx);
 
+        cx.spawn(async move |this, cx| {
+            let remote_urls = remote_urls_request
+                .context("No active repository")?
+                .await??;
+            let remote_provider_icons = cx.update(|cx| remote_provider_icons(&remote_urls, cx));
+            this.update(cx, |this, cx| {
+                this.picker.update(cx, |picker, cx| {
+                    picker.delegate.remote_provider_icons = remote_provider_icons;
+                    cx.notify();
+                });
+            })?;
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+
         Self {
             picker,
             picker_focus_handle,
@@ -416,19 +448,36 @@ impl BranchList {
         })
     }
 
-    pub fn handle_filter(
+    pub(crate) fn set_branch_filter(
         &mut self,
-        _: &branch_picker::FilterRemotes,
+        branch_filter: BranchFilter,
         window: &mut Window,
         cx: &mut Context,
     ) {
+        cx.set_global(GlobalBranchFilter(branch_filter));
         self.picker.update(cx, |picker, cx| {
-            picker.delegate.branch_filter = picker.delegate.branch_filter.invert();
+            if picker.delegate.branch_filter == branch_filter {
+                return;
+            }
+            picker.delegate.branch_filter = branch_filter;
             picker.update_matches(picker.query(cx), window, cx);
             picker.refresh_placeholder(window, cx);
             cx.notify();
         });
     }
+
+    pub(crate) fn branch_filter_menu_open(&self, cx: &App) -> bool {
+        self.picker
+            .read(cx)
+            .delegate
+            .branch_filter_menu_handle
+            .is_deployed()
+    }
+
+    pub(crate) fn cycle_branch_filter(&mut self, window: &mut Window, cx: &mut Context) {
+        let branch_filter = self.picker.read(cx).delegate.branch_filter.next();
+        self.set_branch_filter(branch_filter, window, cx);
+    }
 }
 impl ModalView for BranchList {}
 impl EventEmitter for BranchList {}
@@ -446,11 +495,46 @@ impl Render for BranchList {
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .on_action(cx.listener(Self::handle_delete))
             .on_action(cx.listener(Self::handle_force_delete))
-            .on_action(cx.listener(Self::handle_filter))
+            .on_action(
+                cx.listener(|this, _: &branch_picker::ShowAllBranches, window, cx| {
+                    this.set_branch_filter(BranchFilter::All, window, cx);
+                }),
+            )
+            .on_action(
+                cx.listener(|this, _: &branch_picker::ShowLocalBranches, window, cx| {
+                    this.set_branch_filter(BranchFilter::Local, window, cx);
+                }),
+            )
+            .on_action(
+                cx.listener(|this, _: &branch_picker::ShowRemoteBranches, window, cx| {
+                    this.set_branch_filter(BranchFilter::Remote, window, cx);
+                }),
+            )
+            .on_action(
+                cx.listener(|this, _: &branch_picker::CycleBranchFilter, window, cx| {
+                    this.cycle_branch_filter(window, cx);
+                }),
+            )
+            .on_action(
+                cx.listener(|this, _: &branch_picker::ToggleFilterMenu, window, cx| {
+                    let menu_handle = this
+                        .picker
+                        .read(cx)
+                        .delegate
+                        .branch_filter_menu_handle
+                        .clone();
+                    menu_handle.toggle(window, cx);
+                }),
+            )
             .child(self.picker.clone())
             .when(!self.embedded, |this| {
                 this.on_mouse_down_out({
                     cx.listener(move |this, _, window, cx| {
+                        // The filter menu is a deferred popover, so clicks within it are outside
+                        // the branch picker's bounds even though it is part of this interaction.
+                        if this.branch_filter_menu_open(cx) {
+                            return;
+                        }
                         this.picker.update(cx, |this, cx| {
                             this.cancel(&Default::default(), window, cx);
                         })
@@ -507,22 +591,70 @@ impl Entry {
 }
 
 #[derive(Clone, Copy, PartialEq)]
-enum BranchFilter {
+pub(crate) enum BranchFilter {
     /// Show both local and remote branches.
     All,
+    /// Only show local branches.
+    Local,
     /// Only show remote branches.
     Remote,
 }
 
 impl BranchFilter {
-    fn invert(&self) -> Self {
+    fn next(self) -> Self {
         match self {
-            BranchFilter::All => BranchFilter::Remote,
-            BranchFilter::Remote => BranchFilter::All,
+            Self::All => Self::Local,
+            Self::Local => Self::Remote,
+            Self::Remote => Self::All,
+        }
+    }
+
+    fn label(self) -> &'static str {
+        match self {
+            Self::All => "All Branches",
+            Self::Local => "Local Branches",
+            Self::Remote => "Remote Branches",
         }
     }
 }
 
+struct GlobalBranchFilter(BranchFilter);
+
+impl Global for GlobalBranchFilter {}
+
+fn branch_filter_menu(
+    branch_filter: BranchFilter,
+    focus_handle: FocusHandle,
+    window: &mut Window,
+    cx: &mut App,
+) -> Entity {
+    ContextMenu::build(window, cx, |mut menu, _, _| {
+        menu = menu.context(focus_handle.clone());
+
+        let filter_actions: [(BranchFilter, Box); 3] = [
+            (BranchFilter::All, ShowAllBranches.boxed_clone()),
+            (BranchFilter::Local, ShowLocalBranches.boxed_clone()),
+            (BranchFilter::Remote, ShowRemoteBranches.boxed_clone()),
+        ];
+
+        for (filter, action) in filter_actions {
+            let handler_focus = focus_handle.clone();
+            let dispatched = action.boxed_clone();
+            menu = menu.toggleable_entry(
+                filter.label(),
+                filter == branch_filter,
+                IconPosition::End,
+                Some(action),
+                move |window, cx| {
+                    window.focus(&handler_focus, cx);
+                    window.dispatch_action(dispatched.boxed_clone(), cx);
+                },
+            );
+        }
+        menu
+    })
+}
+
 pub struct BranchListDelegate {
     workspace: WeakEntity,
     matches: Vec,
@@ -535,12 +667,14 @@ pub struct BranchListDelegate {
     last_query: String,
     modifiers: Modifiers,
     branch_filter: BranchFilter,
+    branch_filter_menu_handle: PopoverMenuHandle,
     state: PickerState,
     branch_selection_behavior: BranchSelectionBehavior,
     focus_handle: FocusHandle,
     restore_selected_branch: Option,
     show_footer: bool,
     hovered_delete_index: Option,
+    remote_provider_icons: HashMap,
 }
 
 enum BranchSelectionBehavior {
@@ -781,43 +915,52 @@ fn sort_branch_entries(
     matches: &mut [Entry],
     branch_selection_context: Option<&BranchSelectionContext>,
 ) {
+    let selected_branch_is_remote = branch_selection_context
+        .and_then(|context| context.selected_branch.as_ref())
+        .and_then(|selected_branch| {
+            matches.iter().find_map(|entry| {
+                let branch = entry.as_branch()?;
+                branch_matches_ref(branch, selected_branch).then(|| branch.is_remote())
+            })
+        })
+        .unwrap_or(false);
+
     matches.sort_by_key(|entry| {
         let Some(branch) = entry.as_branch() else {
-            return (4, false);
+            return (true, 4);
         };
 
         let priority = branch_selection_context
             .map(|context| context.priority(branch))
             .unwrap_or(0);
-        (priority, branch.is_remote())
+        (branch.is_remote() != selected_branch_is_remote, priority)
     });
 }
 
-fn process_branches(
-    branches: &Arc<[Branch]>,
-    preserved_branch: Option<&SharedString>,
-) -> Vec {
-    let remote_upstreams: HashSet<_> = branches
-        .iter()
-        .filter_map(|branch| {
-            branch
-                .upstream
-                .as_ref()
-                .filter(|upstream| upstream.is_remote())
-                .map(|upstream| upstream.ref_name.clone())
-        })
-        .collect();
-
-    let mut result: Vec = branches
-        .iter()
-        .filter(|branch| {
-            !remote_upstreams.contains(&branch.ref_name)
-                || preserved_branch
+// Tracked remote branches are:
+// - collapsed when checking out to avoid detaching HEAD.
+// - kept when selecting a diff base because they may point to a different commit.
+fn process_branches(branches: &Arc<[Branch]>, collapse_tracked_remotes: bool) -> Vec {
+    let mut result: Vec = if collapse_tracked_remotes {
+        let remote_upstreams: HashSet<_> = branches
+            .iter()
+            .filter_map(|branch| {
+                branch
+                    .upstream
                     .as_ref()
-                    .is_some_and(|preserved_branch| branch_matches_ref(branch, preserved_branch))
-        })
-        .cloned()
-        .collect();
+                    .filter(|upstream| upstream.is_remote())
+                    .map(|upstream| upstream.ref_name.clone())
+            })
+            .collect();
+
+        branches
+            .iter()
+            .filter(|branch| !remote_upstreams.contains(&branch.ref_name))
+            .cloned()
+            .collect()
+    } else {
+        branches.to_vec()
+    };
 
     result.sort_by_key(|branch| {
         (
@@ -846,6 +989,9 @@ impl BranchListDelegate {
                 selected_branch, ..
             } => selected_branch.clone(),
         };
+        let branch_filter = cx
+            .try_global::()
+            .map_or(BranchFilter::All, |filter| filter.0);
 
         Self {
             workspace,
@@ -858,13 +1004,15 @@ impl BranchListDelegate {
             selected_index: 0,
             last_query: Default::default(),
             modifiers: Default::default(),
-            branch_filter: BranchFilter::All,
+            branch_filter,
+            branch_filter_menu_handle: PopoverMenuHandle::default(),
             state: PickerState::List,
             branch_selection_behavior,
             focus_handle: cx.focus_handle(),
             restore_selected_branch,
             show_footer: false,
             hovered_delete_index: None,
+            remote_provider_icons: HashMap::default(),
         }
     }
 
@@ -872,6 +1020,27 @@ impl BranchListDelegate {
         self.branch_selection_behavior.is_select_only()
     }
 
+    fn branch_filter_trigger(&self) -> IconButton {
+        IconButton::new("branch-filter", IconName::Filter)
+            .icon_size(IconSize::Small)
+            .toggle_state(self.branch_filter != BranchFilter::All)
+            .when(self.branch_filter != BranchFilter::All, |this| {
+                this.indicator(Indicator::dot().color(Color::Info))
+            })
+    }
+
+    fn branch_filter_tooltip(&self) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView + 'static {
+        let focus_handle = self.focus_handle.clone();
+        move |_, cx| {
+            Tooltip::for_action_in(
+                "Filter Branches",
+                &branch_picker::ToggleFilterMenu,
+                &focus_handle,
+                cx,
+            )
+        }
+    }
+
     fn is_force_delete_hovering_index(&self, index: usize) -> bool {
         self.modifiers.alt && self.hovered_delete_index == Some(index)
     }
@@ -1042,8 +1211,28 @@ impl BranchListDelegate {
     }
 }
 
+fn remote_provider_icons(
+    remote_urls: &HashMap,
+    cx: &App,
+) -> HashMap {
+    let Some(provider_registry) = GitHostingProviderRegistry::try_global(cx) else {
+        return HashMap::default();
+    };
+
+    remote_urls
+        .iter()
+        .filter_map(|(remote_name, remote_url)| {
+            let (provider, _) = parse_git_remote_url(provider_registry.clone(), remote_url)?;
+            Some((
+                remote_name.clone(),
+                crate::get_provider_icon(&provider.name()),
+            ))
+        })
+        .collect()
+}
+
 impl PickerDelegate for BranchListDelegate {
-    type ListItem = ListItem;
+    type ListItem = AnyElement;
 
     fn name() -> &'static str {
         "branch picker"
@@ -1073,73 +1262,81 @@ impl PickerDelegate for BranchListDelegate {
         editor: &Arc,
         _window: &mut Window,
         _cx: &mut Context>,
-    ) -> Div {
-        let focus_handle = self.focus_handle.clone();
+    ) -> Option
{ let editor = editor.as_any().downcast_ref::>().unwrap(); + let editor_start = matches!(self.editor_position(), PickerEditorPosition::Start); + let editor_bottom = matches!(self.editor_position(), PickerEditorPosition::End); - let show_inline_filter = - self.editor_position() == PickerEditorPosition::End || !self.show_footer; - - v_flex() - .when( - self.editor_position() == PickerEditorPosition::End, - |this| this.child(Divider::horizontal()), - ) - .when_some(self.branch_list_error.clone(), |this, error| { + let warning_banner = || { + self.branch_list_error.as_deref().map(|error| { let message = format!("Some branches could not be loaded: {error}"); - this.child( - div() - .id("branch-list-error") - .p_1p5() - .child( - Banner::new().severity(Severity::Warning).child( - Label::new(message.clone()) - .size(LabelSize::Small) - .single_line() - .truncate(), - ), - ) - .tooltip(Tooltip::text(message)), + div().p_1p5().child( + Banner::new() + .severity(Severity::Warning) + .child(div().min_w_0().flex_1().child(Label::new(message))), ) }) - .child( - h_flex() - .overflow_hidden() - .flex_none() - .h_9() - .px_2p5() - .child(editor.clone()) - .when(show_inline_filter, |this| { - let tooltip_label = match self.branch_filter { - BranchFilter::All => "Filter Remote Branches", - BranchFilter::Remote => "Show All Branches", - }; + }; - this.gap_1().justify_between().child({ - IconButton::new("filter-remotes", IconName::Filter) - .toggle_state(self.branch_filter == BranchFilter::Remote) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - tooltip_label, - &branch_picker::FilterRemotes, - &focus_handle, - cx, + Some( + v_flex() + .w_full() + .min_w_0() + .when(editor_bottom, |this| { + this.child(Divider::horizontal()) + .when_some(warning_banner(), |this, banner| this.child(banner)) + }) + .child( + h_flex() + .h_9() + .px_2p5() + .flex_none() + .overflow_hidden() + .child(editor.clone()) + .map(|this| { + let branch_filter = self.branch_filter; + let focus_handle = self.focus_handle.clone(); + + this.gap_1().justify_between().child( + PopoverMenu::new("branch-filter-menu") + .with_handle(self.branch_filter_menu_handle.clone()) + .trigger_with_tooltip( + self.branch_filter_trigger(), + self.branch_filter_tooltip(), ) - }) - .on_click(|_click, window, cx| { - window.dispatch_action( - branch_picker::FilterRemotes.boxed_clone(), - cx, - ); - }) - }) - }), - ) - .when( - self.editor_position() == PickerEditorPosition::Start, - |this| this.child(Divider::horizontal()), - ) + .menu(move |window, cx| { + Some(branch_filter_menu( + branch_filter, + focus_handle.clone(), + window, + cx, + )) + }) + .map(|this| { + if editor_bottom { + this.anchor(gpui::Anchor::BottomRight) + .attach(gpui::Anchor::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(-1.0), + }) + } else { + this.anchor(gpui::Anchor::TopRight) + .attach(gpui::Anchor::BottomRight) + .offset(gpui::Point { + x: px(1.0), + y: px(1.0), + }) + } + }), + ) + }), + ) + .when(editor_start, |this| { + this.child(Divider::horizontal()) + .when_some(warning_banner(), |this, banner| this.child(banner)) + }), + ) } fn editor_position(&self) -> PickerEditorPosition { @@ -1153,6 +1350,34 @@ impl PickerDelegate for BranchListDelegate { } } + fn render_header( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + if self.branch_filter == BranchFilter::All + || !self + .matches + .first() + .is_some_and(|entry| entry.as_branch().is_some()) + { + return None; + } + + Some( + div() + .pt_1p5() + .mb_neg_0p5() + .child(ListSubHeader::new(self.branch_filter.label()).inset(true)) + .into_any_element(), + ) + } + + fn has_another_open_menu(&self, window: &Window, cx: &App) -> bool { + self.branch_filter_menu_handle.is_deployed() + || self.branch_filter_menu_handle.is_focused(window, cx) + } + fn match_count(&self) -> usize { self.matches.len() } @@ -1189,15 +1414,16 @@ impl PickerDelegate for BranchListDelegate { cx.spawn_in(window, async move |picker, cx| { let branch_matches_filter = |branch: &Branch| match branch_filter { BranchFilter::All => true, + BranchFilter::Local => !branch.is_remote(), BranchFilter::Remote => branch.is_remote(), }; let mut matches: Vec = if query.is_empty() { let mut matches: Vec = all_branches - .into_iter() + .iter() .filter(|branch| branch_matches_filter(branch)) .map(|branch| Entry::Branch { - branch, + branch: branch.clone(), positions: Vec::new(), }) .collect(); @@ -1256,7 +1482,7 @@ impl PickerDelegate for BranchListDelegate { if !picker.delegate.is_select_only() && !query.is_empty() - && !matches.first().is_some_and(|entry| entry.name() == query) + && !all_branches.iter().any(|branch| branch.name() == query) { let query = normalize_branch_name(&query); let is_url = query.trim_start_matches("git@").parse::().is_ok(); @@ -1448,7 +1674,11 @@ impl PickerDelegate for BranchListDelegate { if is_checked_branch { IconName::Check } else if branch.is_remote() { - IconName::Screen + branch + .remote_name() + .and_then(|remote_name| self.remote_provider_icons.get(remote_name)) + .copied() + .unwrap_or(IconName::Server) } else { IconName::GitBranch } @@ -1544,172 +1774,195 @@ impl PickerDelegate for BranchListDelegate { .into_any_element() }); - Some( - ListItem::new(format!("vcs-menu-{ix}")) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_2p5() - .flex_grow_1() - .child( - Icon::new(entry_icon) - .color(if is_checked_branch { - Color::Accent - } else { - Color::Muted - }) - .size(IconSize::Small), - ) - .child( - v_flex() - .id("info_container") - .w_full() - .child(entry_title) - .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() - } + let list_item = ListItem::new(format!("vcs-menu-{ix}")) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + h_flex() + .w_full() + .gap_2p5() + .flex_grow_1() + .child( + Icon::new(entry_icon) + .color(if is_checked_branch { + Color::Accent + } else { + Color::Muted + }) + .size(IconSize::Small), + ) + .child( + v_flex() + .id("info_container") + .w_full() + .child(entry_title) + .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(), + } + 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) }; - 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), + 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| { + let absolute_time = absolute_time.clone(); + this.tooltip({ + let is_head = is_head_branch; + let is_checked = is_checked_branch; + let is_select_only = self.is_select_only(); + Tooltip::element(move |_, _| { + v_flex() + .child(Label::new(branch_name.clone())) + .when(is_select_only && is_checked, |this| { + this.child( + Label::new("Selected Branch") + .size(LabelSize::Small) + .color(Color::Muted), ) - }) - .when_some(subject, |this, subj| { - this.when(has_commit, |this| this.child(dot())) - .child( - Label::new(subj.to_string()) - .color(Color::Muted) + }) + .when(is_head, |this| { + this.child( + Label::new("Current Branch") .size(LabelSize::Small) - .truncate() - .flex_1(), + .color(Color::Muted), ) - }) - .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| { - let absolute_time = absolute_time.clone(); - this.tooltip({ - let is_head = is_head_branch; - let is_checked = is_checked_branch; - let is_select_only = self.is_select_only(); - Tooltip::element(move |_, _| { - v_flex() - .child(Label::new(branch_name.clone())) - .when(is_select_only && is_checked, |this| { - this.child( - Label::new("Selected Branch") - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - .when(is_head, |this| { - this.child( - Label::new("Current Branch") - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - .when_some( - absolute_time.clone(), - |this, time| { - this.child( - Label::new(time) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }, + }) + .when_some(absolute_time.clone(), |this, time| { + this.child( + Label::new(time) + .size(LabelSize::Small) + .color(Color::Muted), ) - .into_any_element() - }) + }) + .into_any_element() }) - }, - ), - ), - ) - .when( - !self.is_select_only() && !is_new_items && !is_head_branch, - |this| { - this.end_slot(deleted_branch_icon(ix)) - .show_end_slot_on_hover() - }, - ) - .when_some( - if is_new_items { - create_from_default_button + }) + }, + ), + ), + ) + .when( + !self.is_select_only() && !is_new_items && !is_head_branch, + |this| { + this.end_slot(deleted_branch_icon(ix)) + .show_end_slot_on_hover() + }, + ) + .when_some( + if is_new_items { + create_from_default_button + } else { + None + }, + |this, create_from_default_button| { + this.end_slot(create_from_default_button) + .show_end_slot_on_hover() + }, + ); + + let section_header = (self.branch_filter == BranchFilter::All) + .then(|| entry.as_branch()) + .flatten() + .and_then(|branch| { + let starts_section = ix == 0 + || self.matches[ix - 1] + .as_branch() + .is_none_or(|previous_branch| { + previous_branch.is_remote() != branch.is_remote() + }); + starts_section.then(|| { + if branch.is_remote() { + "Remote Branches" } else { - None - }, - |this, create_from_default_button| { - this.end_slot(create_from_default_button) - .show_end_slot_on_hover() - }, - ), + "Local Branches" + } + }) + }); + + Some( + v_flex() + .w_full() + .when_some(section_header, |this, section_header| { + this.pt_1p5() + .child(ListSubHeader::new(section_header).inset(true)) + }) + .child(list_item) + .into_any_element(), ) } @@ -1794,57 +2047,23 @@ impl PickerDelegate for BranchListDelegate { Some( footer_container() - .map(|this| { - if branch_from_default_button.is_some() { - this.justify_end().when_some( - branch_from_default_button, - |this, button| { - this.child(button).child( - Button::new("create", "Create") - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.delegate.confirm(false, window, cx); - })), + .justify_end() + .map(|this| match branch_from_default_button { + Some(button) => this.child(button).child( + Button::new("create", "Create") + .key_binding( + KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, ) - }, - ) - } else { - this.justify_between() - .child({ - let focus_handle = focus_handle.clone(); - let filter_label = match self.branch_filter { - BranchFilter::All => "Filter Remote", - BranchFilter::Remote => "Show All", - }; - Button::new("filter-remotes", filter_label) - .toggle_state(matches!( - self.branch_filter, - BranchFilter::Remote - )) - .key_binding( - KeyBinding::for_action_in( - &branch_picker::FilterRemotes, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_click, window, cx| { - window.dispatch_action( - branch_picker::FilterRemotes.boxed_clone(), - cx, - ); - }) - }) - .child(delete_and_select_btns) - } + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })), + ), + None => this.child(delete_and_select_btns), }) .into_any_element(), ) @@ -1985,8 +2204,7 @@ mod tests { } #[test] - fn test_select_branch_preserves_selected_remote_upstream_and_prioritizes_active_remote_branches() - { + fn test_select_branch_shows_tracked_remotes_and_prioritizes_active_remote_branches() { let selected_branch = SharedString::from("origin/main"); let branches: Arc<[Branch]> = Arc::from([ create_test_branch_with_upstream( @@ -2005,21 +2223,29 @@ mod tests { ), create_test_branch("main", false, Some("origin"), Some(1000)), create_test_branch("feature", false, Some("origin"), Some(900)), + create_test_branch("topic", false, Some("origin"), Some(850)), create_test_branch("main", false, Some("fork"), Some(800)), ]); - let processed_branches = process_branches(&branches, Some(&selected_branch)); + let checkout_branches = process_branches(&branches, true); assert!( - processed_branches + checkout_branches .iter() - .any(|branch| branch.name() == "origin/main"), - "the selected remote branch should be preserved even when a local branch tracks it" + .all(|branch| branch.name() != "origin/main" && branch.name() != "origin/feature"), + "remote branches tracked by a local branch should be collapsed when checking out" + ); + + let processed_branches = process_branches(&branches, false); + assert_eq!( + processed_branches.len(), + branches.len(), + "no branches should be filtered out when selecting a branch" ); assert!( processed_branches .iter() - .all(|branch| branch.name() != "origin/feature"), - "the active branch's unselected remote upstream should still be collapsed" + .any(|branch| branch.name() == "origin/main"), + "remote branches should be selectable even when a local branch tracks them" ); let mut entries = processed_branches @@ -2041,11 +2267,24 @@ mod tests { let ordered_branch_names = entries.iter().map(Entry::name).collect::>(); assert_eq!(ordered_branch_names.first(), Some(&"origin/main")); assert!( - ordered_branch_names.iter().position(|name| *name == "main") + entries + .windows(2) + .filter(|entries| { + entries[0].as_branch().map(Branch::is_remote) + != entries[1].as_branch().map(Branch::is_remote) + }) + .count() + <= 1, + "local and remote branches should each form a contiguous section" + ); + assert!( + ordered_branch_names + .iter() + .position(|name| *name == "origin/topic") < ordered_branch_names .iter() .position(|name| *name == "fork/main"), - "branches on the active branch's remote should be prioritized" + "branches on the active branch's remote should be prioritized within their section" ); } @@ -2175,6 +2414,74 @@ mod tests { assert!(last_match.is_new_branch()); }) }); + + for branch_filter in [BranchFilter::Local, BranchFilter::Remote] { + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.branch_filter = branch_filter; + picker + .delegate + .update_matches(String::from("missing-branch"), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert!( + picker.delegate.render_header(window, cx).is_none(), + "create-only results should not be shown under a branch section header" + ); + assert!(matches!( + picker.delegate.matches.as_slice(), + [Entry::NewBranch { .. }] + )); + }) + }); + } + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.branch_filter = BranchFilter::Remote; + picker + .delegate + .update_matches(String::from("feature-ui"), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert!( + picker.delegate.matches.is_empty(), + "an exact branch hidden by the active filter should not be offered for creation" + ); + }) + }); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches(String::from("feature-u"), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert!(matches!( + picker.delegate.matches.as_slice(), + [Entry::NewBranch { .. }] + )); + }) + }); } async fn update_branch_list_matches_with_empty_query( @@ -2597,7 +2904,7 @@ mod tests { } #[gpui::test] - async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) { + async fn test_branch_filter_shows_all_local_and_remote_branches(cx: &mut TestAppContext) { init_test(cx); let branches = vec![ @@ -2650,32 +2957,54 @@ mod tests { branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { - picker.delegate.branch_filter = BranchFilter::Remote; + picker.delegate.branch_filter = BranchFilter::Local; }) }); update_branch_list_matches_with_empty_query(&branch_list, cx).await; - branch_list - .update_in(cx, |branch_list, window, cx| { - branch_list.picker.update(cx, |picker, cx| { - assert_eq!(picker.delegate.matches.len(), 2); - let branches = picker + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!( + picker .delegate .matches .iter() - .map(|be| be.name()) - .collect::>(); - assert_eq!( - branches, - ["origin/main", "fork/feature-auth"] - .into_iter() - .collect::>() - ); + .map(|entry| entry.name()) + .collect::>(), + ["feature-ui", "develop"].into_iter().collect() + ); + picker.delegate.branch_filter = BranchFilter::Remote; + }) + }); - // Verify the last entry is NOT the "create new branch" option - let last_match = picker.delegate.matches.last().unwrap(); - assert!(!last_match.is_new_url()); + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 2); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["origin/main", "fork/feature-auth"] + .into_iter() + .collect::>() + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_url()); + }) + }); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { picker.delegate.branch_filter = BranchFilter::Remote; picker .delegate @@ -2703,6 +3032,125 @@ mod tests { }); } + #[gpui::test] + async fn test_branch_filter_is_restored_for_new_pickers(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| cx.set_global(GlobalBranchFilter(BranchFilter::Local))); + + let (branch_list, mut ctx) = init_branch_list_test(None, create_test_branches(), cx).await; + branch_list.update(&mut ctx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _| { + assert!(picker.delegate.branch_filter == BranchFilter::Local); + }); + }); + } + + #[test] + fn test_branch_filter_cycle() { + assert!(BranchFilter::All.next() == BranchFilter::Local); + assert!(BranchFilter::Local.next() == BranchFilter::Remote); + assert!(BranchFilter::Remote.next() == BranchFilter::All); + } + + #[gpui::test] + async fn test_select_picker_lists_remote_branch_tracked_by_local_branch( + cx: &mut TestAppContext, + ) { + init_test(cx); + let (project, repository) = init_fake_repository(cx).await; + cx.run_until_parked(); + + // Local `main` tracks `origin/main`; the two can point to different + // commits, so both must be offered when picking a diff base. + let branches = vec![ + create_test_branch_with_upstream( + "main", + true, + None, + Some(1000), + Some("refs/remotes/origin/main"), + ), + create_test_branch("main", false, Some("origin"), Some(900)), + ]; + repository.update(cx, |repository, cx| { + repository.set_branch_list_for_test(branches, cx); + }); + + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + + let checkout_list = window_handle + .update(cx, |_, window, cx| { + cx.new(|cx| { + BranchList::new( + workspace.downgrade(), + Some(repository.clone()), + BranchListStyle::Modal, + rems(34.), + window, + cx, + ) + }) + }) + .unwrap(); + let select_list = window_handle + .update(cx, |_, window, cx| { + cx.new(|cx| { + BranchList::new_select( + workspace.downgrade(), + Some(repository.clone()), + BranchListStyle::Modal, + rems(34.), + Some("main".into()), + Arc::new(|_: Branch, _: &mut Window, _: &mut App| {}), + window, + cx, + ) + }) + }) + .unwrap(); + + let mut ctx = VisualTestContext::from_window(window_handle.into(), cx); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&checkout_list, cx).await; + checkout_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + let names = picker + .delegate + .matches + .iter() + .map(Entry::name) + .collect::>(); + assert_eq!( + names, + vec!["main"], + "the checkout picker should collapse remote branches tracked by a local branch" + ); + }) + }); + + update_branch_list_matches_with_empty_query(&select_list, cx).await; + select_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + let names = picker + .delegate + .matches + .iter() + .map(Entry::name) + .collect::>(); + assert_eq!( + names, + ["main", "origin/main"].into_iter().collect::>(), + "the select picker should offer both a local branch and its remote upstream" + ); + }) + }); + } + #[gpui::test] async fn test_new_branch_creation_with_query(cx: &mut TestAppContext) { const MAIN_BRANCH: &str = "main"; diff --git a/crates/git_ui/src/commit_context_menu.rs b/crates/git_ui/src/commit_context_menu.rs new file mode 100644 index 00000000000000..e3affe8732e827 --- /dev/null +++ b/crates/git_ui/src/commit_context_menu.rs @@ -0,0 +1,243 @@ +use crate::commit_view::CommitView; +use git::Oid; +use gpui::{Action, ClipboardItem, Entity, FocusHandle, SharedString, WeakEntity, Window, actions}; +use project::{GIT_COMMAND_TASK_TAG, git_store::Repository}; + +use task::{TaskContext, TaskVariables, VariableName}; +use ui::{Color, ContextMenu, ContextMenuEntry, IconName, IconPosition, prelude::*}; +use workspace::Workspace; + +actions!( + git_graph, + [ + /// Copies the SHA of the selected commit to the clipboard. + CopyCommitSha, + /// Copies a tag from the selected commit to the clipboard. + CopyCommitTag, + /// Opens the commit view for the selected commit. + OpenCommitView, + ] +); + +const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); +const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; + +pub(crate) struct CommitContextMenuData { + pub(crate) sha: Oid, + pub(crate) tag_names: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommitContextMenuSource { + GitGraph, + GitPanel, +} + +pub(crate) fn commit_context_menu( + commit: CommitContextMenuData, + source: CommitContextMenuSource, + ref_name: Option, + focus_handle: FocusHandle, + repository: Option>, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) -> Entity { + let sha = commit.sha; + let sha_short = sha.display_short(); + let git_tasks = git_context_menu_tasks( + git_task_context(&repository, sha, ref_name.as_deref(), cx), + &workspace, + cx, + ); + let header = match &ref_name { + Some(ref_name) => format!("Ref {ref_name}"), + None => format!("Commit {sha_short}"), + }; + + ContextMenu::build(window, cx, move |context_menu, _, _| { + context_menu + .context(focus_handle) + .header(header) + .entry("View Commit", Some(OpenCommitView.boxed_clone()), { + let repository = repository.clone(); + let workspace = workspace.clone(); + move |window, cx| { + let Some(repository) = repository.clone() else { + return; + }; + CommitView::open( + sha.to_string(), + repository, + workspace.clone(), + None, + None, + window, + cx, + ); + } + }) + .entry( + "Copy SHA", + Some(CopyCommitSha.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(sha.to_string())); + }, + ) + .when_some(ref_name.clone(), |menu, ref_name| { + menu.entry("Copy Ref Name", None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); + }) + }) + .when(ref_name.is_none(), |menu| { + menu.map(|menu| { + let tag_names = commit.tag_names.clone(); + let copy_tag_label = "Copy Tag"; + + match tag_names.as_slice() { + [] => menu.item( + ContextMenuEntry::new(copy_tag_label) + .action(CopyCommitTag.boxed_clone()) + .disabled(true), + ), + [tag_name] => { + let tag_name = tag_name.clone(); + let label = format!("{copy_tag_label}: {tag_name}"); + menu.entry( + label, + Some(CopyCommitTag.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name.to_string(), + )); + }, + ) + } + _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { + let mut menu = menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); + + for tag_name in tag_names.clone() { + let tag_name_to_copy = tag_name.clone(); + menu = menu.entry(tag_name, None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name_to_copy.to_string(), + )); + }); + } + menu + }), + } + }) + }) + .when(source == CommitContextMenuSource::GitPanel, |menu| { + menu.entry("Show in Git Graph", None, move |window, cx| { + window.dispatch_action( + Box::new(crate::git_graph::OpenAtCommit { + sha: sha.to_string(), + }), + cx, + ); + }) + }) + .map(|mut menu| { + menu = menu.separator().header("Custom Commands"); + + if git_tasks.is_empty() { + return menu.item( + ContextMenuEntry::new("Learn More") + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_position(IconPosition::End) + .handler(|_window, cx| { + let docs_url = + release_channel::docs_url(CUSTOM_GIT_COMMANDS_DOCS_SLUG, cx); + cx.open_url(&docs_url); + }), + ); + } + + for (task_source_kind, resolved_task) in git_tasks { + let label = resolved_task.display_label().to_string(); + let workspace = workspace.clone(); + menu = menu.entry(label, None, move |window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind.clone(), + resolved_task.clone(), + false, + window, + cx, + ); + }) + .ok(); + }); + } + + menu + }) + }) +} + +fn git_task_context( + repository: &Option>, + commit_sha: git::Oid, + ref_name: Option<&str>, + cx: &App, +) -> Option { + let repository_path = repository + .as_ref()? + .upgrade()? + .read(cx) + .work_directory_abs_path + .to_path_buf(); + let repository_name = repository_path + .file_name() + .and_then(|name| name.to_str()) + .map(ToString::to_string); + let mut task_variables = TaskVariables::from_iter([ + (VariableName::GitSha, commit_sha.to_string()), + (VariableName::GitShaShort, commit_sha.display_short()), + ( + VariableName::GitRepositoryPath, + repository_path.to_string_lossy().into_owned(), + ), + ]); + + if let Some(repository_name) = repository_name { + task_variables.insert(VariableName::GitRepositoryName, repository_name); + } + if let Some(ref_name) = ref_name { + task_variables.insert(VariableName::GitRef, ref_name.to_string()); + } + + Some(TaskContext { + cwd: Some(repository_path), + task_variables, + ..TaskContext::default() + }) +} + +fn git_context_menu_tasks( + task_context: Option, + workspace: &WeakEntity, + cx: &App, +) -> Vec<(project::TaskSourceKind, task::ResolvedTask)> { + let Some(task_context) = task_context else { + return Vec::new(); + }; + let Some(workspace) = workspace.upgrade() else { + return Vec::new(); + }; + let project = workspace.read(cx).project().clone(); + let task_inventory = project.read_with(cx, |project, cx| { + project.task_store().read(cx).task_inventory().cloned() + }); + let Some(task_inventory) = task_inventory else { + return Vec::new(); + }; + + task_inventory + .read(cx) + .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, &task_context) +} diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 50090a559ca8fd..7ba5558dd95361 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -8,7 +8,8 @@ use git::{Amend, Commit, GenerateCommitMessage, Signoff}; use project::DisableAiSettings; use settings::Settings; use ui::{ - ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*, + ButtonLike, ContextMenu, ElevationIndex, KeybindingHint, PopoverMenu, PopoverMenuHandle, + SplitButton, Tooltip, prelude::*, }; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; @@ -267,17 +268,18 @@ impl CommitModal { &self, id: impl Into, keybinding_target: Option, + disabled: bool, ) -> impl IntoElement { + let menu_open = self.commit_menu_handle.is_deployed(); + PopoverMenu::new(id.into()) + .with_handle(self.commit_menu_handle.clone()) .trigger( - ui::ButtonLike::new_rounded_right("commit-split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::None) - .child( - div() - .px_1() - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), + crate::render_split_button_chevron_trigger( + "modal-commit-split-button-right", + menu_open, + ) + .disabled(disabled), ) .menu({ let git_panel_entity = self.git_panel.clone(); @@ -327,7 +329,10 @@ impl CommitModal { })) } }) - .with_handle(self.commit_menu_handle.clone()) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) .anchor(Anchor::TopRight) } @@ -342,6 +347,7 @@ impl CommitModal { is_amend_pending, is_signoff_enabled, workspace, + is_generating, ) = self.git_panel.update(cx, |git_panel, cx| { let (can_commit, tooltip) = git_panel.configure_commit_button(cx); let title = git_panel.commit_button_title(); @@ -350,6 +356,7 @@ impl CommitModal { let active_repo = git_panel.active_repository.clone(); let is_amend_pending = git_panel.amend_pending(); let is_signoff_enabled = git_panel.signoff_enabled(); + let is_generating = git_panel.is_generating_commit_message(); ( can_commit, tooltip, @@ -360,6 +367,7 @@ impl CommitModal { is_amend_pending, is_signoff_enabled, git_panel.workspace.clone(), + is_generating, ) }); @@ -370,13 +378,12 @@ impl CommitModal { .unwrap_or_else(|| "".to_owned()); let branch_picker_button = Button::new("branch_picker_button", branch) + .label_size(LabelSize::Small) .start_icon( Icon::new(IconName::GitBranch) .size(IconSize::Small) .color(Color::Placeholder), ) - .style(ButtonStyle::Transparent) - .color(Color::Muted) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); })); @@ -401,6 +408,7 @@ impl CommitModal { x: px(0.0), y: px(-2.0), }); + let focus_handle = self.focus_handle(cx); let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, cx).map(|close_kb| { @@ -409,13 +417,12 @@ impl CommitModal { h_flex() .group("commit_editor_footer") - .flex_none() - .w_full() - .items_center() - .justify_between() .w_full() .h(px(self.properties.footer_height)) + .w_full() .gap_1() + .flex_none() + .justify_between() .child( h_flex() .gap_1() @@ -430,65 +437,55 @@ impl CommitModal { .children(generate_commit_message) .children(co_authors), ) - .child(div().flex_1()) .child( h_flex() - .items_center() - .justify_end() - .flex_none() - .px_1() .gap_4() .child(close_kb_hint) .child(SplitButton::new( - ui::ButtonLike::new_rounded_left(ElementId::Name( - format!("split-button-left-{}", commit_label).into(), - )) - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::Compact) - .child( - div() - .child(Label::new(commit_label).size(LabelSize::Small)) - .mr_0p5(), - ) - .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { - telemetry::event!("Git Committed", source = "Git Modal"); - this.git_panel.update(cx, |git_panel, cx| { - git_panel.commit_changes( - CommitOptions { - amend: is_amend_pending, - signoff: is_signoff_enabled, - allow_empty: false, - }, - window, - cx, - ) - }); - cx.emit(DismissEvent); - })) - .disabled(!can_commit) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if can_commit { - Tooltip::with_meta_in( - tooltip, - Some(&git::Commit), - format!( - "git commit{}{}", - if is_amend_pending { " --amend" } else { "" }, - if is_signoff_enabled { " --signoff" } else { "" } - ), - &focus_handle.clone(), + ButtonLike::new_rounded_left(format!("split-button-left-{}", commit_label)) + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) + .disabled(!can_commit) + .child(Label::new(commit_label).size(LabelSize::Small).mr_0p5()) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + telemetry::event!("Git Committed", source = "Git Modal"); + this.git_panel.update(cx, |git_panel, cx| { + git_panel.commit_changes( + CommitOptions { + amend: is_amend_pending, + signoff: is_signoff_enabled, + allow_empty: false, + }, + window, cx, ) - } else { - Tooltip::simple(tooltip, cx) + }); + cx.emit(DismissEvent); + })) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + format!( + "git commit{}{}", + if is_amend_pending { " --amend" } else { "" }, + if is_signoff_enabled { " --signoff" } else { "" } + ), + &focus_handle.clone(), + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } } - } - }), + }), self.render_git_commit_menu( - ElementId::Name(format!("split-button-right-{}", commit_label).into()), + format!("split-button-right-{}", commit_label), Some(focus_handle), + is_generating, ) .into_any_element(), )), diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 9d4c8966068234..14a24f763513f4 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -5,8 +5,8 @@ use git::blame::BlameEntry; use git::repository::CommitSummary; use git::{GitRemote, commit::ParsedCommitMessage}; use gpui::{ - AbsoluteLength, App, Asset, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle, - StatefulInteractiveElement, WeakEntity, prelude::*, + AbsoluteLength, App, Asset, Element, Entity, MouseButton, ParentElement, Pixels, Render, + ScrollHandle, StatefulInteractiveElement, WeakEntity, prelude::*, }; use markdown::{Markdown, MarkdownElement}; use project::git_store::Repository; @@ -14,7 +14,7 @@ use settings::Settings; use std::hash::Hash; use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset}; -use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container}; +use ui::{Avatar, Chip, CopyButton, Divider, Tooltip, prelude::*, tooltip_container}; use workspace::Workspace; #[derive(Clone, Debug)] @@ -24,8 +24,55 @@ pub struct CommitDetails { pub author_email: SharedString, pub commit_time: OffsetDateTime, pub message: Option, + pub tag_names: Vec, } +const MAX_COMMIT_TOOLTIP_TAG_CHIPS: usize = 2; + +pub(crate) fn commit_tag_chips(tag_names: &[SharedString]) -> Option { + if tag_names.is_empty() { + return None; + } + + let (visible_tags, hidden_tags) = + tag_names.split_at(tag_names.len().min(MAX_COMMIT_TOOLTIP_TAG_CHIPS)); + + Some( + h_flex().max_w(relative(0.6)).gap_1().child( + h_flex() + .gap_1() + .min_w_0() + .children( + visible_tags + .iter() + .map(|tag_name| Chip::new(tag_name.clone()).truncate()), + ) + .when(!hidden_tags.is_empty(), |this| { + let hidden_tags = hidden_tags.to_vec(); + this.child(Chip::new(format!("+{}", hidden_tags.len())).tooltip( + Tooltip::element(move |_window, cx| { + v_flex() + .gap_1() + .children(itertools::Itertools::intersperse_with( + hidden_tags.iter().map(|tag_name| { + Label::new(tag_name.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .into_any_element() + }), + || Divider::horizontal().into_any_element(), + )) + .into_any_element() + }), + )) + }) + .child(Divider::vertical()), + ), + ) +} + +const COMMIT_AVATAR_BORDER_WIDTH: Pixels = px(1.); + pub struct CommitAvatar<'a> { sha: &'a SharedString, author_email: Option, @@ -64,21 +111,22 @@ impl<'a> CommitAvatar<'a> { self } + pub fn rendered_size(size: impl Into, window: &Window) -> Pixels { + size.into().to_pixels(window.rem_size()) + COMMIT_AVATAR_BORDER_WIDTH * 2. + } + pub fn render(&'a self, window: &mut Window, cx: &mut App) -> AnyElement { let border_color = cx.theme().colors().border_variant; - let border_width = px(1.); match self.avatar(window, cx) { None => { - let container_size = self - .size - .map(|s| s.to_pixels(window.rem_size()) + border_width * 2.); + let container_size = self.size.map(|size| Self::rendered_size(size, window)); h_flex() .when_some(container_size, |this, size| this.size(size)) .justify_center() .rounded_full() - .border(border_width) + .border(COMMIT_AVATAR_BORDER_WIDTH) .border_color(border_color) .bg(cx.theme().colors().element_disabled) .child( @@ -172,6 +220,7 @@ impl CommitTooltip { pub fn blame_entry( blame: &BlameEntry, details: Option, + tag_names: Vec, repository: Entity, workspace: WeakEntity, cx: &mut Context, @@ -192,6 +241,7 @@ impl CommitTooltip { .into(), author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(), message: details, + tag_names, }, repository, workspace, @@ -270,6 +320,7 @@ impl Render for CommitTooltip { .message .as_ref() .and_then(|details| details.pull_request.clone()); + let tag_names = self.commit.tag_names.clone(); let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4); @@ -336,12 +387,16 @@ impl Render for CommitTooltip { .w_full() .justify_between() .pt_1() + .gap_1() + .flex_wrap() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(absolute_timestamp) .child( h_flex() .gap_1() + .min_w_0() + .children(commit_tag_chips(&tag_names)) .when_some(pull_request, |this, pr| { this.child( Button::new( diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 818e75c346adcd..cc11492a421272 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -2,8 +2,8 @@ use anyhow::{Context as _, Result}; use buffer_diff::BufferDiff; use collections::HashMap; use editor::{ - Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, - hover_markdown_style, multibuffer_context_lines, + Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyDiffHunkDelegate, + SplittableEditor, hover_markdown_style, multibuffer_context_lines, }; use futures_lite::future::yield_now; use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content}; @@ -275,7 +275,7 @@ impl CommitView { window, cx, ); - editor.disable_diff_hunk_controls(cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); @@ -1201,7 +1201,7 @@ impl Item for CommitView { window, cx, ); - editor.disable_diff_hunk_controls(cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 309a118078128a..ea30f61bde543a 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -17,7 +17,7 @@ use project::{ use settings::Settings; use std::{ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, Tooltip, prelude::*}; -use util::{debug_panic, maybe}; +use util::debug_panic; use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, @@ -115,19 +115,17 @@ pub(crate) fn buffer_ranges_updated( return; } - let buffer_conflicts = editor - .addon_mut::() - .unwrap() - .buffers - .entry(buffer_id) - .or_insert_with(|| { - let subscription = cx.subscribe(&conflict_set, conflicts_updated); - BufferConflicts { - block_ids: Vec::new(), - conflict_set: conflict_set.clone(), - _subscription: subscription, - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + let buffer_conflicts = conflict_addon.buffers.entry(buffer_id).or_insert_with(|| { + let subscription = cx.subscribe(&conflict_set, conflicts_updated); + BufferConflicts { + block_ids: Vec::new(), + conflict_set: conflict_set.clone(), + _subscription: subscription, + } + }); let conflict_set = buffer_conflicts.conflict_set.clone(); let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len(); @@ -150,18 +148,17 @@ pub(crate) fn buffers_removed( cx: &mut Context, ) { let mut removed_block_ids = HashSet::default(); - editor - .addon_mut::() - .unwrap() - .buffers - .retain(|buffer_id, buffer| { - if removed_buffer_ids.contains(buffer_id) { - removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); - false - } else { - true - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + conflict_addon.buffers.retain(|buffer_id, buffer| { + if removed_buffer_ids.contains(buffer_id) { + removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); + false + } else { + true + } + }); editor.remove_blocks(removed_block_ids, None, cx); } @@ -176,9 +173,13 @@ fn conflicts_updated( let conflict_set = conflict_set.read(cx).snapshot(); let multibuffer = editor.buffer().read(cx); let snapshot = multibuffer.snapshot(cx); - let old_range = maybe!({ - let conflict_addon = editor.addon_mut::().unwrap(); - let buffer_conflicts = conflict_addon.buffers.get(&buffer_id)?; + let old_range = { + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + let Some(buffer_conflicts) = conflict_addon.buffers.get(&buffer_id) else { + return; + }; match buffer_conflicts.block_ids.get(event.old_range.clone()) { Some(_) => Some(event.old_range.clone()), None => { @@ -197,10 +198,12 @@ fn conflicts_updated( } } } - }); + }; // Remove obsolete highlights and blocks - let conflict_addon = editor.addon_mut::().unwrap(); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; if let Some((buffer_conflicts, old_range)) = conflict_addon .buffers .get_mut(&buffer_id) @@ -256,7 +259,9 @@ fn conflicts_updated( } let new_block_ids = editor.insert_blocks(blocks, None, cx); - let conflict_addon = editor.addon_mut::().unwrap(); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; if let Some((buffer_conflicts, old_range)) = conflict_addon.buffers.get_mut(&buffer_id).zip(old_range) { @@ -481,7 +486,7 @@ pub(crate) fn resolve_conflict( let buffer_id = resolved_conflict.ours.end.buffer_id; let buffer = multibuffer.read(cx).buffer(buffer_id)?; resolved_conflict.resolve(buffer.clone(), &ranges, cx); - let conflict_addon = editor.addon_mut::().unwrap(); + let conflict_addon = editor.addon_mut::()?; let snapshot = multibuffer.read(cx).snapshot(cx); let buffer_snapshot = buffer.read(cx).snapshot(); let state = conflict_addon @@ -637,6 +642,8 @@ impl Render for MergeConflictIndicator { .border_color(border_color) .child( ButtonLike::new("update-button") + .tab_index(0isize) + .aria_label(message.clone()) .child( h_flex() .h_full() diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs new file mode 100644 index 00000000000000..b20ed284035bef --- /dev/null +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -0,0 +1,1038 @@ +use crate::{ + conflict_view, + git_panel::{GitPanel, GitStatusEntry}, + git_panel_settings::GitPanelSettings, +}; +use anyhow::Result; +use buffer_diff::BufferDiff; +use collections::{HashMap, HashSet}; +use editor::{ + EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::GoToHunk, + multibuffer_context_lines, scroll::Autoscroll, +}; +use futures_lite::future::yield_now; +use git::{repository::RepoPath, status::FileStatus}; +use gpui::{ + App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; +use multi_buffer::{MultiBuffer, PathKey}; +use project::{ + ConflictSet, Project, ProjectPath, + git_store::{ + Repository, + diff_buffer_list::{self, BranchDiffEvent, DiffBase}, + }, +}; +use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; +use std::{collections::BTreeMap, sync::Arc}; +use theme::ActiveTheme; +use ui::{CommonAnimationExt as _, KeyBinding, prelude::*}; +use util::{ResultExt as _, rel_path::RelPath}; +use workspace::{ + CloseActiveItem, ItemNavHistory, Workspace, + item::{Item, SaveOptions}, +}; +use ztracing::instrument; + +struct BufferSubscriptions { + _diff: Entity, + display_buffer: Entity, + _diff_subscription: Subscription, + _conflict_set: Option>, + _conflict_set_subscription: Option, +} + +pub struct DiffMultibuffer { + multibuffer: Entity, + branch_diff: Entity, + editor: Entity, + buffer_subscriptions: HashMap, + workspace: WeakEntity, + focus_handle: FocusHandle, + pending_scroll: Option, + review_comment_count: usize, + empty_label: SharedString, + _task: Task>, + _subscription: Subscription, +} + +impl DiffMultibuffer { + pub(crate) fn new( + branch_diff: Entity, + multibuffer_capability: Capability, + empty_label: impl Into, + configure_editor: impl FnOnce(&mut SplittableEditor, &mut Context) + 'static, + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(multibuffer_capability); + multibuffer.set_all_diff_hunks_expanded(cx); + multibuffer + }); + let editor = cx.new(|cx| { + let mut diff_display_editor = SplittableEditor::new( + EditorSettings::get_global(cx).diff_view_style, + multibuffer.clone(), + project.clone(), + workspace.clone(), + window, + cx, + ); + configure_editor(&mut diff_display_editor, cx); + diff_display_editor.rhs_editor().update(cx, |editor, cx| { + editor.set_show_diff_review_button(true, cx); + }); + diff_display_editor + }); + let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event); + + let primary_editor = editor.read(cx).rhs_editor().clone(); + let review_comment_subscription = + cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| { + if let EditorEvent::ReviewCommentsChanged { total_count } = event { + this.review_comment_count = *total_count; + cx.notify(); + } + }); + + let branch_diff_subscription = cx.subscribe_in( + &branch_diff, + window, + move |this, _git_store, event, window, cx| match event { + BranchDiffEvent::FileListChanged => { + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + BranchDiffEvent::DiffBaseChanged => { + this.pending_scroll.take(); + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + }, + ); + + let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; + let mut was_group_by = GitPanelSettings::get_global(cx).group_by; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; + let mut was_collapse_untracked_diff = + GitPanelSettings::get_global(cx).collapse_untracked_diff; + cx.observe_global_in::(window, move |this, window, cx| { + let settings = GitPanelSettings::get_global(cx); + let sort_by = settings.sort_by; + let group_by = settings.group_by; + let tree_view = settings.tree_view; + let is_collapse_untracked_diff = settings.collapse_untracked_diff; + if sort_by != was_sort_by + || group_by != was_group_by + || tree_view != was_tree_view + || is_collapse_untracked_diff != was_collapse_untracked_diff + { + this._task = { + window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + } + was_sort_by = sort_by; + was_group_by = group_by; + was_tree_view = tree_view; + was_collapse_untracked_diff = is_collapse_untracked_diff; + }) + .detach(); + + let task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }); + + Self { + workspace: workspace.downgrade(), + branch_diff, + focus_handle, + editor, + multibuffer, + buffer_subscriptions: Default::default(), + pending_scroll: None, + review_comment_count: 0, + empty_label: empty_label.into(), + _task: task, + _subscription: Subscription::join( + branch_diff_subscription, + Subscription::join(editor_subscription, review_comment_subscription), + ), + } + } + + pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { + self.branch_diff.read(cx).diff_base() + } + + pub(crate) fn branch_diff(&self) -> &Entity { + &self.branch_diff + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.branch_diff.read(cx).repo().cloned() + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.branch_diff.update(cx, |branch_diff, cx| { + branch_diff.set_repo(repo, cx); + }); + } + + pub(crate) fn is_dirty(&self, cx: &App) -> bool { + self.multibuffer.read(cx).is_dirty(cx) + } + + pub(crate) fn has_conflict(&self, cx: &App) -> bool { + self.multibuffer.read(cx).has_conflict(cx) + } + + pub(crate) fn multibuffer(&self) -> &Entity { + &self.multibuffer + } + + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { + return; + }; + let repo = git_repo.read(cx); + let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx); + + self.move_to_path(path_key, window, cx) + } + + pub(crate) fn move_to_project_path( + &mut self, + project_path: &ProjectPath, + window: &mut Window, + cx: &mut Context, + ) { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { + return; + }; + let Some(repo_path) = git_repo + .read(cx) + .project_path_to_repo_path(project_path, cx) + else { + return; + }; + let status = git_repo + .read(cx) + .status_for_path(&repo_path) + .map(|entry| entry.status) + .unwrap_or(FileStatus::Untracked); + let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx); + self.move_to_path(path_key, window, cx) + } + + pub(crate) fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); + }); + }); + }); + } + + pub(crate) fn move_to_path( + &mut self, + path_key: PathKey, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::focused()), + window, + cx, + |s| { + s.select_ranges([position..position]); + }, + ) + }) + }); + } else { + self.pending_scroll = Some(path_key); + } + } + + pub(crate) fn autoscroll(&self, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.request_autoscroll(Autoscroll::fit(), cx); + }) + }) + } + + pub(crate) fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { + self.multibuffer.read(cx).snapshot(cx).total_changed_lines() + } + + /// Returns the total count of review comments across all hunks/files. + pub(crate) fn total_review_comment_count(&self) -> usize { + self.review_comment_count + } + + /// Returns a reference to the splittable editor. + pub(crate) fn editor(&self) -> &Entity { + &self.editor + } + + pub(crate) fn selected_ranges( + &self, + cx: &App, + ) -> (bool, Vec>) { + let editor = self.editor.read(cx).rhs_editor().read(cx); + let snapshot = self.multibuffer.read(cx).snapshot(cx); + let mut selection = true; + let mut ranges = editor + .selections + .disjoint_anchor_ranges() + .collect::>(); + if !ranges.iter().any(|range| range.start != range.end) { + selection = false; + let anchor = editor.selections.newest_anchor().head(); + if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) + && let Some(range) = snapshot + .anchor_in_buffer(excerpt_range.context.start) + .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) + .map(|(start, end)| start..end) + { + ranges = vec![range]; + } else { + ranges = Vec::default(); + }; + } + + (selection, ranges) + } + + /// Ranges for a toolbar stage/unstage action: the selection, or the cursor + /// (a zero-width range that resolves to the single hunk under it) when + /// there is no selection. Unlike [`Self::selected_ranges`], this never + /// widens to the whole excerpt, so actions affect one hunk at a time. + fn hunk_action_ranges(&self, cx: &App) -> Vec> { + self.editor + .read(cx) + .rhs_editor() + .read(cx) + .selections + .disjoint_anchor_ranges() + .collect() + } + + pub(crate) fn stage_or_unstage_selected_hunks( + &mut self, + stage: bool, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let editor = self.editor.read(cx).rhs_editor().clone(); + let ranges = self.hunk_action_ranges(cx); + // Route through the editor's delegated stage path, the same path taken + // by the hunk buttons (on either side of a split) and the keyboard. + // For staging, dirty buffers are saved first, exactly as they are when + // staging from the uncommitted diff or a normal editor. + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks(stage, ranges, window, cx); + }); + if move_to_next { + editor + .focus_handle(cx) + .dispatch_action(&GoToHunk, window, cx); + } + } + + pub(crate) fn restore_selected_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let editor = self.editor.read(cx).rhs_editor().clone(); + let ranges = self.hunk_action_ranges(cx); + editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let hunks: Vec<_> = editor.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + if !hunks.is_empty() { + editor.apply_restore(hunks, window, cx); + } + }); + if move_to_next { + editor + .focus_handle(cx) + .dispatch_action(&GoToHunk, window, cx); + } + } + + fn handle_editor_event( + &mut self, + editor: &Entity, + event: &EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + EditorEvent::SelectionsChanged { local: true } => { + // Only follow the git panel selection from the view the user is + // actually interacting with. Background (non-active) diff views + // refresh on their own and must not hijack the panel selection. + if !editor.focus_handle(cx).contains_focused(window, cx) { + return; + } + let Some(project_path) = self.active_project_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); + } + EditorEvent::Saved => { + self._task = + cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); + } + + _ => {} + } + if editor.focus_handle(cx).contains_focused(window, cx) + && self.multibuffer.read(cx).is_empty() + { + self.focus_handle.focus(window, cx) + } + } + + #[instrument(skip_all)] + fn register_buffer( + &mut self, + repo_path: RepoPath, + path_key: PathKey, + file_status: FileStatus, + display_buffer: Entity, + main_buffer: Entity, + diff: Entity, + conflict_set: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let diff_subscription = cx.subscribe_in(&diff, window, { + let repo_path = repo_path.clone(); + let path_key = path_key.clone(); + let display_buffer = display_buffer.clone(); + let main_buffer = main_buffer.clone(); + let diff = diff.clone(); + let conflict_set = conflict_set.clone(); + move |this, _, event, window, cx| match event { + buffer_diff::BufferDiffEvent::DiffChanged(_) => { + this.buffer_ranges_changed( + repo_path.clone(), + path_key.clone(), + file_status, + display_buffer.clone(), + main_buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ); + } + buffer_diff::BufferDiffEvent::BaseTextChanged => {} + } + }); + let conflict_set_subscription = conflict_set.as_ref().map(|conflict_set| { + cx.subscribe_in(conflict_set, window, { + let repo_path = repo_path.clone(); + let path_key = path_key.clone(); + let display_buffer = display_buffer.clone(); + let main_buffer = main_buffer.clone(); + let diff = diff.clone(); + let conflict_set = Some(conflict_set.clone()); + move |this, _, _, window, cx| { + this.buffer_ranges_changed( + repo_path.clone(), + path_key.clone(), + file_status, + display_buffer.clone(), + main_buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ) + } + }) + }); + self.buffer_subscriptions.insert( + repo_path, + BufferSubscriptions { + _diff: diff.clone(), + display_buffer: display_buffer.clone(), + _diff_subscription: diff_subscription, + _conflict_set: conflict_set.clone(), + _conflict_set_subscription: conflict_set_subscription, + }, + ); + + let snapshot = display_buffer.read(cx).snapshot(); + let diff_snapshot = diff.read(cx).snapshot(cx); + + let excerpt_ranges = { + let diff_hunk_ranges = diff_snapshot + .hunks_intersecting_range( + Anchor::min_max_range_for_buffer(snapshot.remote_id()), + &snapshot, + ) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); + let conflict_ranges = conflict_set.as_ref().and_then(|conflict_set| { + let conflicts = conflict_set.read(cx).snapshot(); + let conflicts = conflicts + .conflicts + .iter() + .map(|conflict| conflict.range.to_point(&snapshot)) + .collect::>(); + (!conflicts.is_empty()).then_some(conflicts) + }); + + conflict_ranges.unwrap_or_else(|| diff_hunk_ranges.collect()) + }; + + let buffer_id = snapshot.text.remote_id(); + let mut needs_fold = false; + + let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { + let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); + let is_newly_added = editor.update_excerpts_for_path( + path_key.clone(), + display_buffer, + excerpt_ranges, + multibuffer_context_lines(cx), + diff, + cx, + ); + if let Some(conflict_set) = conflict_set { + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffer_ranges_updated(editor, conflict_set, cx); + }); + } + (was_empty, is_newly_added) + }); + + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + if was_empty { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([ + multi_buffer::Anchor::Min..multi_buffer::Anchor::Min + ]) + }, + ); + } + if is_excerpt_newly_added + && (file_status.is_deleted() + || (file_status.is_untracked() + && GitPanelSettings::get_global(cx).collapse_untracked_diff)) + { + needs_fold = true; + } + }) + }); + + if self.multibuffer.read(cx).is_empty() + && self + .editor + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + { + self.focus_handle.focus(window, cx); + } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { + self.editor.update(cx, |editor, cx| { + editor.focus_handle(cx).focus(window, cx); + }); + } + if self.pending_scroll.as_ref() == Some(&path_key) { + self.move_to_path(path_key, window, cx); + } + + needs_fold.then_some(buffer_id) + } + + fn buffer_ranges_changed( + &mut self, + repo_path: RepoPath, + path_key: PathKey, + file_status: FileStatus, + display_buffer: Entity, + main_buffer: Entity, + diff: Entity, + conflict_set: Option>, + window: &mut Window, + cx: &mut Context, + ) { + if display_buffer.read(cx).is_dirty() { + return; + } + self.register_buffer( + repo_path, + path_key, + file_status, + display_buffer, + main_buffer, + diff, + conflict_set, + window, + cx, + ); + } + + #[instrument(skip(this, cx))] + pub(crate) async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + let entries = this.update(cx, |this, cx| { + let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { + let load_buffers = branch_diff.load_buffers(cx); + (branch_diff.repo().cloned(), load_buffers) + }); + let mut previous_paths = this + .multibuffer + .read(cx) + .snapshot(cx) + .buffers_with_paths() + .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) + .collect::>(); + + let mut entries = BTreeMap::new(); + let mut live_repo_paths = HashSet::default(); + if let Some(repo) = repo { + let repo = repo.read(cx); + for diff_buffer in buffers_to_load { + live_repo_paths.insert(diff_buffer.repo_path.clone()); + let path_key = project_diff_path_key( + &repo, + &diff_buffer.repo_path, + diff_buffer.file_status, + cx, + ); + previous_paths.remove(&path_key); + entries.insert(path_key, diff_buffer); + } + } + + let repo_path_by_display_id = this + .buffer_subscriptions + .iter() + .map(|(repo_path, sub)| { + (sub.display_buffer.read(cx).remote_id(), repo_path.clone()) + }) + .collect::>(); + + this.editor.update(cx, |editor, cx| { + for (path, buffer_id) in previous_paths { + if let Some(repo_path) = repo_path_by_display_id.get(&buffer_id) { + this.buffer_subscriptions.remove(repo_path); + } + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffers_removed(editor, &[buffer_id], cx); + }); + let _span = ztracing::info_span!("remove_excerpts_for_path"); + _span.enter(); + editor.remove_excerpts_for_path(path, cx); + } + }); + + this.buffer_subscriptions + .retain(|repo_path, _| live_repo_paths.contains(repo_path)); + + entries + })?; + + let mut buffers_to_fold = Vec::new(); + + for (path_key, entry) in entries { + if let Some(loaded_buffer) = entry.load.await.log_err() { + // We might be lagging behind enough that all future entry.load futures are no longer pending. + // If that is the case, this task will never yield, starving the foreground thread of execution time. + yield_now().await; + cx.update(|window, cx| { + this.update(cx, |this, cx| { + if let Some(buffer_id) = this.register_buffer( + entry.repo_path, + path_key, + entry.file_status, + loaded_buffer.display_buffer, + loaded_buffer.main_buffer, + loaded_buffer.diff, + loaded_buffer.conflict_set, + window, + cx, + ) { + buffers_to_fold.push(buffer_id); + } + }) + .ok(); + })?; + } + } + this.update(cx, |this, cx| { + if !buffers_to_fold.is_empty() { + this.editor.update(cx, |editor, cx| { + editor + .rhs_editor() + .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); + }); + } + this.pending_scroll.take(); + cx.notify(); + })?; + + Ok(()) + } + + pub(crate) fn active_project_path(&self, cx: &App) -> Option { + let editor = self.editor.read(cx).focused_editor().read(cx); + let multibuffer = editor.buffer().read(cx); + let position = editor.selections.newest_anchor().head(); + let snapshot = multibuffer.snapshot(cx); + let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; + let buffer = multibuffer.buffer(text_anchor.buffer_id)?; + + let file = buffer.read(cx).file()?; + Some(ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + } + + pub(crate) fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.added_to_workspace(workspace, window, cx) + }); + } + + pub(crate) fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.deactivated(window, cx); + }) + }); + } + + pub(crate) fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.navigate(data, window, cx) + }) + }) + } + + pub(crate) fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, _| { + primary_editor.set_nav_history(Some(nav_history)); + }) + }); + } + + pub(crate) fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.editor + .read(cx) + .rhs_editor() + .read(cx) + .for_each_project_item(cx, f) + } + + pub(crate) fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.save(options, project, window, cx) + }) + }) + } + + pub(crate) fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.reload(project, window, cx) + }) + }) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_paths(&self, cx: &App) -> Vec> { + let snapshot = self + .editor() + .read(cx) + .rhs_editor() + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + snapshot + .excerpts() + .map(|excerpt| { + snapshot + .path_for_buffer(excerpt.context.start.buffer_id) + .unwrap() + .path + .clone() + }) + .collect() + } + + /// Returns the real (worktree-relative) path of each excerpted buffer, in + /// the order the excerpts appear in the multibuffer. Unlike + /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather + /// than the (possibly synthetic) `PathKey` path used for sorting. + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_file_paths(&self, cx: &App) -> Vec { + let multibuffer = self + .editor() + .read(cx) + .rhs_editor() + .read(cx) + .buffer() + .clone(); + let snapshot = multibuffer.read(cx).snapshot(cx); + let mut result = Vec::new(); + let mut last_buffer_id = None; + for excerpt in snapshot.excerpts() { + let buffer_id = excerpt.context.start.buffer_id; + if last_buffer_id == Some(buffer_id) { + continue; + } + last_buffer_id = Some(buffer_id); + if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id) + && let Some(file) = buffer.read(cx).file() + { + result.push(file.path().as_unix_str().to_string()); + } + } + result + } +} + +impl EventEmitter for DiffMultibuffer {} + +impl Focusable for DiffMultibuffer { + fn focus_handle(&self, cx: &App) -> FocusHandle { + if self.multibuffer.read(cx).is_empty() { + self.focus_handle.clone() + } else { + self.editor.focus_handle(cx) + } + } +} + +impl Render for DiffMultibuffer { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_empty = self.multibuffer.read(cx).is_empty(); + let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready(); + let empty_label = self.empty_label.clone(); + + div() + .track_focus(&self.focus_handle) + .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .when(is_empty && is_loading, |el| { + let rems = TextSize::Large.rems(cx); + el.child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Custom(rems)) + .color(Color::Accent) + .with_rotate_animation(3) + .into_any_element(), + ) + }) + .when(is_empty && !is_loading, |el| { + let remote_button = if let Some(panel) = self + .workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).panel::(cx)) + { + panel.update(cx, |panel, cx| panel.render_remote_button(cx)) + } else { + None + }; + let keybinding_focus_handle = self.focus_handle(cx); + el.child( + v_flex() + .gap_1() + .child(h_flex().justify_around().child(Label::new(empty_label))) + .map(|el| match remote_button { + Some(button) => el.child(h_flex().justify_around().child(button)), + None => el.child( + h_flex() + .justify_around() + .child(Label::new("Remote up to date")), + ), + }) + .child( + h_flex().justify_around().mt_1().child( + Button::new("project-diff-close-button", "Close") + .key_binding(KeyBinding::for_action_in( + &CloseActiveItem::default(), + &keybinding_focus_handle, + cx, + )) + .on_click(move |_, window, cx| { + window.focus(&keybinding_focus_handle, cx); + window.dispatch_action( + Box::new(CloseActiveItem::default()), + cx, + ); + }), + ), + ), + ) + }) + .when(!is_empty, |el| el.child(self.editor.clone())) + } +} + +const CONFLICT_SORT_PREFIX: u64 = 1; +const TRACKED_SORT_PREFIX: u64 = 2; +const NEW_SORT_PREFIX: u64 = 3; + +/// Computes a stable [`PathKey`] for a buffer in the project diff. +/// +/// The key is an intrinsic function of the file's own repo path and status; it +/// never depends on which other buffers happen to be present in the +/// multibuffer. This is required because the multibuffer uses the path key both +/// to order excerpts and to identify which excerpts belong to a given buffer, so +/// a key that shifted as files were added or removed would break that identity. +/// +/// Status grouping is encoded in the `sort_prefix`, and the within-group order +/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural +/// ordering reproduces the git panel's order. The path here is only ever used +/// for sorting and multibuffer identity; the path shown in the UI comes from the +/// buffer's own `File`. +pub(crate) fn project_diff_path_key( + repo: &Repository, + repo_path: &RepoPath, + status: FileStatus, + cx: &App, +) -> PathKey { + let settings = GitPanelSettings::get_global(cx); + let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { + TRACKED_SORT_PREFIX + } else if repo.had_conflict_on_last_merge_head_change(repo_path) { + CONFLICT_SORT_PREFIX + } else if status.is_created() { + NEW_SORT_PREFIX + } else { + TRACKED_SORT_PREFIX + }; + let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); + PathKey::with_sort_prefix(sort_prefix, path) +} + +fn project_diff_sort_path( + repo_path: &RelPath, + tree_view: bool, + sort_by: GitPanelSortBy, +) -> Arc { + if tree_view { + tree_sort_path(repo_path) + } else { + match sort_by { + GitPanelSortBy::Path => repo_path.into_arc(), + GitPanelSortBy::Name => name_sort_path(repo_path), + } + } +} + +/// Builds a synthetic path that sorts by file name first, falling back to the +/// full path to keep the key unique per file. +fn name_sort_path(repo_path: &RelPath) -> Arc { + let Some(file_name) = repo_path.file_name() else { + return repo_path.into_arc(); + }; + let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); + RelPath::from_unix_str(&synthetic) + .map(|path| path.into_arc()) + .unwrap_or_else(|_| repo_path.into_arc()) +} + +/// Builds a synthetic path whose natural component-wise ordering reproduces a +/// folder-first tree order. Each directory component is prefixed with a NUL +/// byte, which can never appear in a real path component and sorts before every +/// printable character, so at each level directories sort before files. +fn tree_sort_path(repo_path: &RelPath) -> Arc { + let components: Vec<&str> = repo_path.components().collect(); + if components.len() <= 1 { + return repo_path.into_arc(); + } + let last = components.len() - 1; + let mut synthetic = String::new(); + for (index, component) in components.into_iter().enumerate() { + if index > 0 { + synthetic.push('/'); + } + if index < last { + synthetic.push('\0'); + } + synthetic.push_str(component); + } + RelPath::from_unix_str(&synthetic) + .map(|path| path.into_arc()) + .unwrap_or_else(|_| repo_path.into_arc()) +} diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 477f18be545dbc..a9c6b5878d8a13 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -2,13 +2,16 @@ use anyhow::Result; use buffer_diff::BufferDiff; -use editor::{Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor}; +use editor::{ + Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + SplittableEditor, +}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, IntoElement, Render, Task, WeakEntity, Window, }; -use language::{Buffer, HighlightedText}; +use language::{Buffer, HighlightedText, Point}; use project::{Project, ProjectPath}; use settings::Settings; use std::{ @@ -41,6 +44,7 @@ impl FileDiffView { pub fn open( old_path: PathBuf, new_path: PathBuf, + target_position: Option, workspace: WeakEntity, window: &mut Window, cx: &mut App, @@ -75,6 +79,23 @@ impl FileDiffView { pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx); }); + if let Some(target_position) = target_position { + let (new_buffer, rhs_editor) = { + let diff_view = diff_view.read(cx); + ( + diff_view.new_buffer.clone(), + diff_view.editor.read(cx).rhs_editor().clone(), + ) + }; + let point = new_buffer + .read(cx) + .snapshot() + .point_from_external_input(target_position.row, target_position.column); + rhs_editor.update(cx, |editor, cx| { + editor.go_to_singleton_buffer_point(point, window, cx); + }); + } + diff_view }) }) @@ -103,16 +124,31 @@ impl FileDiffView { window, cx, ); - splittable.rhs_editor().update(cx, |editor, _| { - editor.start_temporary_diff_override(); - }); - splittable.disable_diff_hunk_controls(cx); - splittable.set_render_diff_hunks_as_unstaged(cx); + splittable + .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); splittable }); let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(()); + // The buffers' languages may load after the diff was built, e.g. when + // opening the view on startup via `zed --diff`. Propagate them to the + // base text buffer, which the split view's left-hand side displays. + cx.subscribe(&new_buffer, { + let base_text_buffer = diff.read(cx).base_text_buffer().downgrade(); + move |_, buffer, event, cx| { + if let language::BufferEvent::LanguageChanged(_) = event { + let language = buffer.read(cx).language().cloned(); + base_text_buffer + .update(cx, |base_text_buffer, cx| { + base_text_buffer.set_language_async(language, cx); + }) + .ok(); + } + } + }) + .detach(); + for buffer in [&old_buffer, &new_buffer] { cx.subscribe(buffer, move |this, _, event, _| match event { language::BufferEvent::Edited { .. } @@ -236,7 +272,7 @@ impl Item for FileDiffView { .to_string(), ) }) - .unwrap_or_else(|| "untitled".into()) + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()) }; let old_filename = title_text(&self.old_buffer); let new_filename = title_text(&self.new_buffer); @@ -250,7 +286,7 @@ impl Item for FileDiffView { .read(cx) .file() .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned()) - .unwrap_or_else(|| "untitled".into()) + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()) }; let old_path = path(&self.old_buffer); let new_path = path(&self.new_buffer); @@ -373,6 +409,7 @@ mod tests { use editor::test::editor_test_context::assert_state_with_diff; use gpui::BorrowAppContext; use gpui::TestAppContext; + use language::{Language, LanguageConfig}; use project::{FakeFs, Fs, Project}; use settings::{DiffViewStyle, SettingsStore}; use std::path::PathBuf; @@ -418,6 +455,7 @@ mod tests { FileDiffView::open( path!("/test/old_file.txt").into(), path!("/test/new_file.txt").into(), + None, workspace.weak_handle(), window, cx, @@ -534,6 +572,85 @@ mod tests { }) } + #[gpui::test] + async fn test_split_diff_view_highlights_base_text_after_language_load( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Split); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + serde_json::json!({ + "old_file.rs": "fn main() {}\n", + "new_file.rs": "fn main() { unimplemented!() }\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let diff_view = workspace + .update_in(cx, |workspace, window, cx| { + FileDiffView::open( + path!("/test/old_file.rs").into(), + path!("/test/new_file.rs").into(), + None, + workspace.weak_handle(), + window, + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + // Language detection completes only after the diff view was created, + // as happens on startup with `zed -n --diff old new`. + let language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + None, + )); + diff_view.update(cx, |diff_view, cx| { + diff_view.new_buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language.clone()), cx); + }); + }); + cx.run_until_parked(); + + let lhs_language = diff_view.read_with(cx, |diff_view, cx| { + let lhs_editor = diff_view + .editor + .read(cx) + .lhs_editor() + .expect("diff view should be split") + .clone(); + let buffer = lhs_editor + .read(cx) + .buffer() + .read(cx) + .all_buffers() + .into_iter() + .next() + .expect("lhs multibuffer should have a buffer"); + buffer.read(cx).language().map(|language| language.name()) + }); + assert_eq!(lhs_language, Some("Rust".into())); + } + #[gpui::test] async fn test_save_changes_in_diff_view(cx: &mut TestAppContext) { init_test(cx); @@ -559,6 +676,7 @@ mod tests { FileDiffView::open( PathBuf::from(path!("/test/old_file.txt")), PathBuf::from(path!("/test/new_file.txt")), + None, workspace.weak_handle(), window, cx, diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 384474e21056ae..6e1ddd12de3c52 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,5 +1,7 @@ +pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView}; use crate::{ - commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, + commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu}, + commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon, }; @@ -8,7 +10,6 @@ use editor::Editor; use file_icons::FileIcons; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, - commit::ParsedCommitMessage, parse_git_remote_url, repository::{ CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, @@ -17,18 +18,19 @@ use git::{ status::{FileStatus, StatusCode, TrackedStatus}, }; use gpui::{ - Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, - DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollStrategy, + Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, + DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, + MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; use language::line_diff; +use markdown::{Markdown, MarkdownElement}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use picker::{Picker, PickerDelegate}; use project::{ - GIT_COMMAND_TASK_TAG, ProjectPath, TaskSourceKind, + ProjectPath, git_store::{ CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository, RepositoryEvent, RepositoryId, @@ -46,17 +48,18 @@ use std::{ sync::{Arc, OnceLock}, time::{Duration, Instant}, }; -use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; + use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, DiffStat, - Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing, + Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, + HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, - prelude::*, render_redistributable_columns_resize_handles, render_table_header, - table_row::TableRow, + prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, + render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow, }; +use util::{ResultExt, debug_panic}; use workspace::{ ModalView, Workspace, item::{Item, ItemEvent, TabTooltipContent}, @@ -70,9 +73,8 @@ const LINE_WIDTH: Pixels = px(1.5); const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); -const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; -// Extra vertical breathing room added to the UI line height when computing -// the git graph's row height, so commit dots and lines have space around them. +const TREE_INDENT: f32 = 20.0; +const TABLE_COLUMN_COUNT: usize = 4; const ROW_VERTICAL_PADDING: Pixels = px(4.0); struct CopiedState { @@ -275,8 +277,6 @@ impl ChangedFileEntry { workspace: WeakEntity, _cx: &App, ) -> AnyElement { - const TREE_INDENT: f32 = 12.0; - let file_name = self.file_name.clone(); let dir_path = self.dir_path.clone(); @@ -355,8 +355,6 @@ struct ChangedFileDirectoryEntry { impl ChangedFileDirectoryEntry { fn render(&self, ix: usize, git_graph: WeakEntity, cx: &App) -> AnyElement { - const TREE_INDENT: f32 = 12.0; - let path = self.path.clone(); let expanded = self.expanded; let folder_icon = FileIcons::get_folder_icon(expanded, path.as_std_path(), cx) @@ -378,22 +376,6 @@ impl ChangedFileDirectoryEntry { .spacing(ListItemSpacing::Sparse) .indent_level(self.depth) .indent_step_size(px(TREE_INDENT)) - .toggle(Some(expanded)) - .always_show_disclosure_icon(true) - .on_toggle({ - let path = path.clone(); - let git_graph = git_graph.clone(); - move |_, _, cx| { - git_graph - .update(cx, |git_graph, cx| { - git_graph - .changed_files_expanded_dirs - .insert(path.clone(), !expanded); - cx.notify(); - }) - .ok(); - } - }) .start_slot(folder_icon) .child( Label::new(self.name.clone()) @@ -598,12 +580,6 @@ actions!( [ /// Opens the Git Graph Tab. Open, - /// Copies the SHA of the selected commit to the clipboard. - CopyCommitSha, - /// Copies a tag from the selected commit to the clipboard. - CopyCommitTag, - /// Opens the commit view for the selected commit. - OpenCommitView, /// Focuses the search field. FocusSearch, /// Focuses the next git graph tab stop. @@ -1228,32 +1204,32 @@ pub fn open_or_reuse_graph( graph.repo_id == repo_id && graph.log_source == log_source }); - if let Some(existing) = existing { - if let Some(sha) = sha { - existing.update(cx, |graph, cx| { + let git_graph = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let workspace_handle = workspace.weak_handle(); + let git_graph = cx.new(|cx| { + GitGraph::new( + repo_id, + git_store, + workspace_handle, + Some(log_source), + window, + cx, + ) + }); + workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx); + git_graph + }; + + if let Some(sha) = sha { + cx.defer(move |cx| { + git_graph.update(cx, |graph, cx| { graph.select_commit_by_sha(sha.as_str(), cx); }); - } - workspace.activate_item(&existing, true, true, window, cx); - return; + }); } - - let workspace_handle = workspace.weak_handle(); - let git_graph = cx.new(|cx| { - let mut graph = GitGraph::new( - repo_id, - git_store, - workspace_handle, - Some(log_source), - window, - cx, - ); - if let Some(sha) = sha { - graph.select_commit_by_sha(sha.as_str(), cx); - } - graph - }); - workspace.add_item_to_active_pane(Box::new(git_graph), None, true, window, cx); } fn lane_center_x(bounds: Bounds, lane: f32) -> Pixels { @@ -1318,10 +1294,16 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) { struct GitGraphContextMenu { menu: Entity, position: Point, - entry_idx: usize, + entry_idx: Option, _subscription: Subscription, } +struct DetailPanelCommitMessage { + sha: Oid, + message: Entity, + scroll_handle: ScrollHandle, +} + pub struct GitGraph { focus_handle: FocusHandle, search_state: SearchState, @@ -1331,6 +1313,9 @@ pub struct GitGraph { context_menu: Option, table_interaction_state: Entity, column_widths: Entity, + /// Per-column visibility mask owned by the view (not the resize state) so columns can be + /// hidden regardless of whether the table is resizable. `true` means the column is hidden. + column_visibility: TableRow, selected_entry_idx: Option, hovered_entry_idx: Option, graph_canvas_bounds: Rc>>>, @@ -1339,6 +1324,8 @@ pub struct GitGraph { selected_commit_diff: Option, selected_commit_diff_stats: Option<(usize, usize)>, _commit_diff_task: Option>, + selected_commit_message: Option, + _selected_commit_message_task: Option>, commit_details_split_state: Entity, repo_id: RepositoryId, changed_files_scroll_handle: UniformListScrollHandle, @@ -1392,22 +1379,32 @@ impl GitGraph { } fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] { - // todo(git_graph): We should make a column/table api that allows removing table columns - let fractions = self + let raw = self .column_widths .read(cx) .preview_fractions(window.rem_size()); + let fractions = redistribute_hidden_fractions(&raw, Some(&self.column_visibility)); + + // Hidden columns occupy no space in the layout, so report them as zero here even though + // the shared redistribution helper preserves their stored width for when they return. + let value = |idx: usize| { + if self.column_visibility.get(idx).copied().unwrap_or(false) { + 0.0 + } else { + fractions[idx] + } + }; let is_path_history = matches!(self.log_source, LogSource::Path(_)); - let graph_fraction = if is_path_history { 0.0 } else { fractions[0] }; + let graph_fraction = if is_path_history { 0.0 } else { value(0) }; let offset = if is_path_history { 0 } else { 1 }; [ graph_fraction, - fractions[offset], - fractions[offset + 1], - fractions[offset + 2], - fractions[offset + 3], + value(offset), + value(offset + 1), + value(offset + 2), + value(offset + 3), ] } @@ -1435,10 +1432,13 @@ impl GitGraph { } fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels { - self.column_widths - .read(cx) - .preview_column_width(0, window) - .unwrap_or_else(|| self.graph_canvas_content_width()) + let container = self.column_widths.read(cx).cached_container_width(); + let graph_fraction = self.preview_column_fractions(window, cx)[0]; + if container > px(0.) && graph_fraction > 0.0 { + container * graph_fraction + } else { + self.graph_canvas_content_width() + } } pub fn new( @@ -1521,6 +1521,14 @@ impl GitGraph { ) }) }; + let column_visibility = TableRow::from_element( + false, + if matches!(log_source, LogSource::Path(_)) { + TABLE_COLUMN_COUNT + } else { + TABLE_COLUMN_COUNT + 1 + }, + ); let mut row_height = Self::row_height(window, cx); cx.observe_global_in::(window, move |this, window, cx| { @@ -1554,11 +1562,14 @@ impl GitGraph { context_menu: None, table_interaction_state, column_widths, + column_visibility, selected_entry_idx: None, hovered_entry_idx: None, graph_canvas_bounds: Rc::new(Cell::new(None)), selected_commit_diff: None, selected_commit_diff_stats: None, + selected_commit_message: None, + _selected_commit_message_task: None, log_source, log_order, commit_details_split_state: cx.new(|_cx| SplitState::new()), @@ -1682,10 +1693,6 @@ impl GitGraph { git_store.repositories().get(&self.repo_id).cloned() } - fn has_context_menu(&self) -> bool { - self.context_menu.is_some() - } - /// Checks whether a ref name from git's `%D` decoration /// format refers to the currently checked-out branch. fn is_head_ref(ref_name: &str, head_branch_name: &Option) -> bool { @@ -1779,7 +1786,6 @@ impl GitGraph { }); let row_height = Self::row_height(window, cx); - let has_context_menu = self.has_context_menu(); // We fetch data outside the visible viewport to avoid loading entries when // users scroll through the git graph @@ -1887,48 +1893,6 @@ impl GitGraph { div() .id(ElementId::NamedInteger("commit-subject".into(), idx as u64)) .overflow_hidden() - .when(!has_context_menu, |this| { - if let CommitDataState::Loaded(commit_data) = &data { - let sha = commit.data.sha.to_string(); - let author_name = commit_data.author_name.clone(); - let author_email = commit_data.author_email.clone(); - let message = commit_data.message.clone(); - let commit_timestamp = commit_data.commit_timestamp; - let workspace = self.workspace.clone(); - let repository = repository.clone(); - this.hoverable_tooltip(move |_window, cx| { - let remote_url = repository.read(cx).default_remote_url(); - let provider_registry = - GitHostingProviderRegistry::default_global(cx); - let commit_details = CommitDetails { - sha: sha.clone().into(), - author_name: author_name.clone(), - author_email: author_email.clone(), - commit_time: OffsetDateTime::from_unix_timestamp( - commit_timestamp, - ) - .unwrap_or_else(|_| OffsetDateTime::now_utc()), - message: Some(ParsedCommitMessage::parse( - sha.clone(), - message.to_string(), - remote_url.as_deref(), - Some(provider_registry), - )), - }; - cx.new(|cx| { - CommitTooltip::new( - commit_details, - repository.clone(), - workspace.clone(), - cx, - ) - }) - .into() - }) - } else { - this - } - }) .child( h_flex() .gap_2() @@ -2179,13 +2143,16 @@ impl GitGraph { return; }; - let sha = commit.data.sha.to_string(); - let Some(repository) = self.get_repository(cx) else { return; }; - let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha)); + let commit_message_handle = commit.data.sha; + let diff_handle = commit.data.sha.to_string(); + + self.load_selected_commit_message(cx, &commit_message_handle, &repository); + + let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(diff_handle)); self._commit_diff_task = Some(cx.spawn(async move |this, cx| { if let Ok(Ok(diff)) = diff_receiver.await { @@ -2203,6 +2170,70 @@ impl GitGraph { cx.notify(); } + fn load_selected_commit_message( + &mut self, + cx: &mut Context<'_, Self>, + sha: &Oid, + repository: &Entity, + ) { + if self + .selected_commit_message + .as_ref() + .is_some_and(|old| old.sha == *sha) + { + return; + } + + self._selected_commit_message_task = None; + match repository.update(cx, |repo, cx| { + repo.fetch_commit_data(*sha, true, cx).clone() + }) { + CommitDataState::Loaded(commit_data) => { + self.set_selected_commit_message(cx, commit_data.sha, commit_data.message.clone()); + } + CommitDataState::Loading(Some(receiver)) => { + self._selected_commit_message_task = Some(cx.spawn(async move |this, cx| { + if let Ok(commit_data) = receiver.await { + this.update(cx, |this, cx| { + this.set_selected_commit_message( + cx, + commit_data.sha, + commit_data.message.clone(), + ); + }) + .log_err(); + } + })) + } + _ => { + debug_panic!( + "Fetched commit data asynchronously, but was not given a listener or cached commit data." + ); + } + }; + } + + fn set_selected_commit_message( + &mut self, + cx: &mut Context<'_, GitGraph>, + sha: Oid, + message: SharedString, + ) { + let languages = self + .workspace + .read_with(cx, |workspace, cx| { + workspace.project().read(cx).languages().clone() + }) + .log_err(); + self.selected_commit_message = Some(DetailPanelCommitMessage { + sha, + message: cx.new(|cx| Markdown::new(message, languages, None, cx)), + scroll_handle: ScrollHandle::new(), + }); + self._selected_commit_message_task = None; + cx.notify(); + } + fn select_previous_match(&mut self, cx: &mut Context) { if self.search_state.matches.is_empty() { return; @@ -2376,91 +2407,6 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } - fn git_task_context( - &self, - commit_sha: Oid, - ref_name: Option<&str>, - cx: &App, - ) -> Option { - let repository_path = self - .get_repository(cx)? - .read(cx) - .work_directory_abs_path - .to_path_buf(); - - let repository_name = repository_path - .file_name() - .and_then(|name| name.to_str()) - .map(ToString::to_string); - - let mut task_variables = TaskVariables::from_iter([ - (VariableName::GitSha, commit_sha.to_string()), - (VariableName::GitShaShort, commit_sha.display_short()), - ( - VariableName::GitRepositoryPath, - repository_path.to_string_lossy().into_owned(), - ), - ]); - - if let Some(repository_name) = repository_name { - task_variables.insert(VariableName::GitRepositoryName, repository_name); - } - - if let Some(ref_name) = ref_name { - task_variables.insert(VariableName::GitRef, ref_name.to_string()); - } - - Some(TaskContext { - cwd: Some(repository_path), - task_variables, - ..TaskContext::default() - }) - } - - fn git_context_menu_tasks( - &self, - task_context: &TaskContext, - cx: &App, - ) -> Vec<(TaskSourceKind, ResolvedTask)> { - let Some(workspace) = self.workspace.upgrade() else { - return Vec::new(); - }; - - let project = workspace.read(cx).project().clone(); - - let task_inventory = project.read_with(cx, |project, cx| { - project.task_store().read(cx).task_inventory().cloned() - }); - - let Some(task_inventory) = task_inventory else { - return Vec::new(); - }; - - task_inventory - .read(cx) - .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, task_context) - } - - fn schedule_git_task( - &mut self, - task_source_kind: TaskSourceKind, - resolved_task: ResolvedTask, - window: &mut Window, - cx: &mut Context, - ) { - self.workspace - .update(cx, |workspace, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok(); - } - fn deploy_entry_context_menu( &mut self, position: Point, @@ -2472,137 +2418,35 @@ impl GitGraph { let Some(commit) = self.graph_data.commits.get(index) else { return; }; - let sha = commit.data.sha; - let sha_short = sha.display_short(); - let git_tasks = self - .git_task_context(sha, ref_name.as_deref(), cx) - .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) - .unwrap_or_default(); - - let header = match &ref_name { - Some(ref_name) => format!("Ref {ref_name}"), - None => format!("Commit {sha_short}"), - }; - - let focus_handle = self.focus_handle.clone(); - let git_graph = cx.entity(); - let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| { - context_menu - .context(focus_handle) - .header(header) - .entry( - "View Commit", - Some(OpenCommitView.boxed_clone()), - window.handler_for(&git_graph, move |this, window, cx| { - this.open_commit_view(index, window, cx); - }), - ) - .entry( - "Copy SHA", - Some(CopyCommitSha.boxed_clone()), - window.handler_for(&git_graph, move |this, _window, cx| { - this.copy_commit_sha(index, cx); - }), - ) - .when_some(ref_name.clone(), |menu, ref_name| { - menu.entry("Copy Ref Name", None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); - }) - }) - .when(ref_name.is_none(), |menu| { - menu.map(|menu| { - let tag_names = commit - .data - .tag_names() - .into_iter() - .map(|tag_name| SharedString::from(tag_name.to_string())) - .collect::>(); - let copy_tag_label = "Copy Tag"; - - match tag_names.as_slice() { - [] => menu.item( - ContextMenuEntry::new(copy_tag_label) - .action(CopyCommitTag.boxed_clone()) - .disabled(true), - ), - [tag_name] => { - let tag_name = tag_name.clone(); - let label = format!("{copy_tag_label}: {tag_name}"); - menu.entry( - label, - Some(CopyCommitTag.boxed_clone()), - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name.to_string(), - )); - }, - ) - } - _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { - let mut menu = - menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); - - for tag_name in tag_names.clone() { - let tag_name_to_copy = tag_name.clone(); - - menu = menu.entry(tag_name, None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name_to_copy.to_string(), - )); - }); - } - menu - }), - } - }) - }) - .map(|mut menu| { - menu = menu.separator().header("Custom Commands"); - - if git_tasks.is_empty() { - return menu.item( - ContextMenuEntry::new("Learn More") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler(|_window, cx| { - let docs_url = release_channel::docs_url( - CUSTOM_GIT_COMMANDS_DOCS_SLUG, - cx, - ); - cx.open_url(&docs_url); - }), - ); - } - - for (task_source_kind, resolved_task) in git_tasks { - let label = resolved_task.display_label().to_string(); - - menu = menu.entry( - label, - None, - window.handler_for(&git_graph, move |this, window, cx| { - this.schedule_git_task( - task_source_kind.clone(), - resolved_task.clone(), - window, - cx, - ); - }), - ); - } - - menu - }) - }); - self.set_context_menu(context_menu, position, index, window, cx); + let repository = self + .get_repository(cx) + .map(|repository| repository.downgrade()); + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.data.sha, + tag_names: commit + .data + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect(), + }, + CommitContextMenuSource::GitGraph, + ref_name, + self.focus_handle.clone(), + repository, + self.workspace.clone(), + window, + cx, + ); + self.set_context_menu(context_menu, position, Some(index), window, cx); } fn set_context_menu( &mut self, context_menu: Entity, position: Point, - entry_idx: usize, + entry_idx: Option, window: &mut Window, cx: &mut Context, ) { @@ -2633,6 +2477,63 @@ impl GitGraph { cx.notify(); } + fn toggle_column_visibility(&mut self, col_idx: usize, cx: &mut Context) { + if let Some(slot) = self.column_visibility.as_mut_slice().get_mut(col_idx) { + *slot = !*slot; + // Column visibility is persisted per item, so schedule a workspace serialization. + cx.emit(ItemEvent::Edit); + } + } + + fn deploy_header_context_menu( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let is_path_history = matches!(self.log_source, LogSource::Path(_)); + let columns: &[&str] = if is_path_history { + &["Description", "Date", "Author", "Commit"] + } else { + &["Graph", "Description", "Date", "Author", "Commit"] + }; + + let filter = self.column_visibility.clone(); + let visible_count = filter + .as_slice() + .iter() + .filter(|filtered| !**filtered) + .count(); + + let focus_handle = self.focus_handle.clone(); + let git_graph = cx.entity(); + let context_menu = ContextMenu::build(window, cx, |mut context_menu, _window, _cx| { + context_menu = context_menu.context(focus_handle).header("Columns"); + for (col_idx, label) in columns.iter().enumerate() { + let is_visible = !filter.get(col_idx).copied().unwrap_or(false); + // Disable hiding the last remaining visible column. + let can_toggle = !is_visible || visible_count > 1; + let git_graph = git_graph.clone(); + context_menu = context_menu.toggleable_entry_disabled_when( + label.to_string(), + is_visible, + !can_toggle, + IconPosition::End, + None, + move |_window, cx| { + git_graph.update(cx, |this, cx| { + this.toggle_column_visibility(col_idx, cx); + cx.notify(); + }); + }, + ); + } + context_menu + }); + + self.set_context_menu(context_menu, position, None, window, cx); + } + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { let color = cx.theme().colors(); let query_focus_handle = self @@ -2815,15 +2716,13 @@ impl GitGraph { .copied() .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); - // todo(git graph): We should use the full commit message here - let (author_name, author_email, commit_timestamp, commit_message) = match &data { + let (author_name, author_email, commit_timestamp) = match &data { CommitDataState::Loaded(data) => ( data.author_name.clone(), data.author_email.clone(), Some(data.commit_timestamp), - data.subject.clone(), ), - CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None, "Loading…".into()), + CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None), }; let date_string = commit_timestamp @@ -2858,7 +2757,7 @@ impl GitGraph { }; CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref()) - .size(px(40.)) + .size(px(32.)) .render(window, cx) }; @@ -2896,6 +2795,25 @@ impl GitGraph { Rc::default() }; + let is_tree_view = self.changed_files_view_mode.is_tree(); + let view_toggle = IconButton::new("toggle-changed-files-view", IconName::ListTree) + .icon_size(IconSize::Small) + .toggle_state(self.changed_files_view_mode.is_tree()) + .tooltip({ + let tooltip = if is_tree_view { + "Show Flat View" + } else { + "Show Tree View" + }; + move |_, cx| Tooltip::for_action(tooltip, &ToggleChangedFilesView, cx) + }) + .on_click(cx.listener(|this, _, _window, cx| { + this.changed_files_view_mode = this.changed_files_view_mode.toggled(); + this.changed_files_scroll_handle + .scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + })); + v_flex() .min_w(px(300.)) .h_full() @@ -2917,6 +2835,8 @@ impl GitGraph { this.selected_entry_idx = None; this.selected_commit_diff = None; this.selected_commit_diff_stats = None; + this.selected_commit_message = None; + this._selected_commit_message_task = None; this.changed_files_expanded_dirs.clear(); this._commit_diff_task = None; cx.notify(); @@ -2928,17 +2848,12 @@ impl GitGraph { .py_1() .w_full() .items_center() - .gap_1() .child(avatar) + .child(Label::new(author_name).mt_1p5()) .child( - v_flex() - .items_center() - .child(Label::new(author_name)) - .child( - Label::new(date_string) - .color(Color::Muted) - .size(LabelSize::Small), - ), + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), ), ) .children((!ref_names.is_empty()).then(|| { @@ -3091,74 +3006,45 @@ impl GitGraph { ), ) .child(Divider::horizontal()) - .child(div().p_2().child(Label::new(commit_message))) + .child(self.render_commit_message(window, cx)) .child(Divider::horizontal()) .child( v_flex() .min_w_0() - .p_2() .flex_1() - .gap_1() + .overflow_hidden() .child( h_flex() + .p_2() + .pr_3() + .pb_1() .gap_1() .w_full() .justify_between() - .child( - Label::new(format!( - "{} Changed {}", - changed_files_count, - if changed_files_count == 1 { - "File" - } else { - "Files" - } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) .child( h_flex() .gap_1() - .child(DiffStat::new( - "commit-diff-stat", - total_lines_added, - total_lines_removed, - )) .child( - IconButton::new( - "toggle-changed-files-view", - IconName::ListTree, - ) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .toggle_state(self.changed_files_view_mode.is_tree()) - .tooltip({ - let tooltip = if self.changed_files_view_mode.is_tree() - { - "Show Flat View" + Label::new(format!( + "{} Changed {}", + changed_files_count, + if changed_files_count == 1 { + "File" } else { - "Show Tree View" - }; - move |_, cx| { - Tooltip::for_action( - tooltip, - &ToggleChangedFilesView, - cx, - ) + "Files" } - }) - .on_click( - cx.listener(|this, _, _window, cx| { - this.changed_files_view_mode = - this.changed_files_view_mode.toggled(); - this.changed_files_scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); - cx.notify(); - }), - ), - ), - ), + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Divider::vertical()) + .child(view_toggle), + ) + .child(DiffStat::new( + "commit-diff-stat", + total_lines_added, + total_lines_removed, + )), ) .child( div() @@ -3167,7 +3053,7 @@ impl GitGraph { .min_h_0() .child({ let flat_entries = changed_file_entries; - let is_tree_view = self.changed_files_view_mode.is_tree(); + let entry_count = if is_tree_view { tree_entries.len() } else { @@ -3177,6 +3063,8 @@ impl GitGraph { let repository = repository.downgrade(); let workspace = self.workspace.clone(); let git_graph = cx.weak_entity(); + let indent_tree_entries = tree_entries.clone(); + uniform_list( "changed-files-list", entry_count, @@ -3219,8 +3107,34 @@ impl GitGraph { .collect() }, ) + .when(is_tree_view, |list| { + list.with_decoration( + ui::indent_guides( + px(TREE_INDENT), + IndentGuideColors::panel(cx), + ) + .with_left_offset( + ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET - px(2.), + ) + .with_compute_indents_fn( + cx.entity(), + move |_, range, _window, _cx| { + range + .map(|ix| match indent_tree_entries.get(ix) { + Some(ChangedFileTreeEntry::Directory( + entry, + )) => entry.depth, + Some(ChangedFileTreeEntry::File(entry)) => { + entry.depth + } + None => 0, + }) + .collect() + }, + ), + ) + }) .size_full() - .ml_neg_1() .track_scroll(&self.changed_files_scroll_handle) }) .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx), @@ -3231,6 +3145,11 @@ impl GitGraph { h_flex().p_1p5().w_full().child( Button::new("view-commit", "View Commit") .full_width() + .start_icon( + Icon::new(IconName::GitCommit) + .size(IconSize::Small) + .color(Color::Muted), + ) .style(ButtonStyle::OutlinedGhost) .on_click(cx.listener(|this, _, window, cx| { this.open_selected_commit_view(window, cx); @@ -3286,7 +3205,7 @@ impl GitGraph { let hovered_entry_idx = self.hovered_entry_idx; let selected_entry_idx = self.selected_entry_idx; - let context_menu_entry_idx = self.context_menu.as_ref().map(|menu| menu.entry_idx); + let context_menu_entry_idx = self.context_menu.as_ref().and_then(|menu| menu.entry_idx); let is_focused = self.focus_handle.is_focused(window); let graph_canvas_bounds = self.graph_canvas_bounds.clone(); @@ -3723,19 +3642,70 @@ impl GitGraph { ) .into_any_element() } -} - -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 (commit_count, is_loading) = self.commit_count_and_loading_state(cx); - let error = self.get_repository(cx).and_then(|repo| { + fn render_commit_message( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let Some(DetailPanelCommitMessage { + message, + scroll_handle, + .. + }) = self.selected_commit_message.as_ref() + else { + return Empty.into_any_element(); + }; + + let message_style = editor::hover_markdown_style(window, cx); + let rem_size = window.rem_size(); + let line_height = message_style + .base_text_style + .line_height_in_pixels(rem_size); + + div() + // Using grid over flexbox because the structure of this side + // panel prvents taffy from calculating a concrete width correctly, + // which causes problems with text reflow when using flexbox. + // grid, on the other hand, doesn't appear to give taffy the same + // problems. + .w_full() + .py_2() + .pl_2() + .grid() + .grid_cols(1) + .gap_1() + .child( + div() + .relative() + .w_full() + .child( + div() + .id("commit-message") + .text_sm() + .w_full() + .max_h(line_height * 12.) + .overflow_y_scroll() + .track_scroll(scroll_handle) + .child(MarkdownElement::new(message.clone(), message_style)), + ) + .vertical_scrollbar_for(scroll_handle, window, cx), + ) + .into_any_element() + } +} + +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 (commit_count, is_loading) = self.commit_count_and_loading_state(cx); + + let error = self.get_repository(cx).and_then(|repo| { repo.read(cx) .get_graph_data(self.log_source.clone(), self.log_order) .and_then(|data| data.error.clone()) @@ -3752,11 +3722,10 @@ impl Render for GitGraph { let label = Label::new(message) .color(Color::Muted) .size(LabelSize::Large); - div() + + h_flex() .size_full() - .h_flex() .gap_1() - .items_center() .justify_center() .child(label) .when(is_loading && error.is_none(), |this| { @@ -3766,10 +3735,27 @@ impl Render for GitGraph { let is_path_history = matches!(self.log_source, LogSource::Path(_)); let header_resize_info = HeaderResizeInfo::from_redistributable(&self.column_widths, cx); - let header_context = TableRenderContext::for_column_widths( - Some(self.column_widths.read(cx).widths_to_render()), - true, + + let column_filter = self.column_visibility.clone(); + + // The graph column (index 0) only exists in the non-path-history layout and is + // rendered as a separate canvas outside the table. + let graph_visible = + is_path_history || !column_filter.get(0usize).copied().unwrap_or(false); + + let table_offset = if is_path_history { 0 } else { 1 }; + let table_filter = column_filter + .as_slice() + .get(table_offset..table_offset + TABLE_COLUMN_COUNT) + .map(|slice| TableRow::from_vec(slice.to_vec(), TABLE_COLUMN_COUNT)) + .unwrap_or_else(|| TableRow::from_element(false, TABLE_COLUMN_COUNT)); + let header_widths = redistribute_hidden_widths( + &self.column_widths.read(cx).widths_to_render(), + Some(&column_filter), ); + let header_context = TableRenderContext::for_column_widths(Some(header_widths), true) + .with_column_filter(Some(column_filter)); + let [ graph_fraction, description_fraction, @@ -3781,56 +3767,81 @@ impl Render for GitGraph { description_fraction + date_fraction + author_fraction + commit_fraction; let table_width_config = self.table_column_width_config(window, cx); + let table_collapsed = table_fraction <= f32::EPSILON; + let graph_content_width = self.graph_canvas_content_width(); + h_flex() .size_full() .child( - div() + v_flex() .flex_1() .min_w_0() .size_full() .flex() .flex_col() - .child(render_table_header( - if !is_path_history { - TableRow::from_vec( - vec![ - Label::new("Graph") - .color(Color::Muted) - .truncate() - .into_any_element(), - Label::new("Description") - .color(Color::Muted) - .into_any_element(), - Label::new("Date").color(Color::Muted).into_any_element(), - Label::new("Author").color(Color::Muted).into_any_element(), - Label::new("Commit").color(Color::Muted).into_any_element(), - ], - 5, - ) - } else { - TableRow::from_vec( - vec![ - Label::new("Description") - .color(Color::Muted) - .into_any_element(), - Label::new("Date").color(Color::Muted).into_any_element(), - Label::new("Author").color(Color::Muted).into_any_element(), - Label::new("Commit").color(Color::Muted).into_any_element(), - ], - 4, + .child( + div() + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.deploy_header_context_menu(event.position, window, cx); + cx.stop_propagation(); + }), ) - }, - header_context, - Some(header_resize_info), - Some(self.column_widths.entity_id()), - cx, - )) + .child(render_table_header( + if !is_path_history { + TableRow::from_vec( + vec![ + Label::new("Graph") + .color(Color::Muted) + .truncate() + .into_any_element(), + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date") + .color(Color::Muted) + .into_any_element(), + Label::new("Author") + .color(Color::Muted) + .into_any_element(), + Label::new("Commit") + .color(Color::Muted) + .into_any_element(), + ], + 5, + ) + } else { + TableRow::from_vec( + vec![ + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date") + .color(Color::Muted) + .into_any_element(), + Label::new("Author") + .color(Color::Muted) + .into_any_element(), + Label::new("Commit") + .color(Color::Muted) + .into_any_element(), + ], + 4, + ) + }, + header_context, + Some(header_resize_info), + Some(self.column_widths.entity_id()), + cx, + )), + ) .child({ let row_height = Self::row_height(window, cx); let selected_entry_idx = self.selected_entry_idx; let hovered_entry_idx = self.hovered_entry_idx; let context_menu_entry_idx = - self.context_menu.as_ref().map(|menu| menu.entry_idx); + self.context_menu.as_ref().and_then(|menu| menu.entry_idx); let weak_self = cx.weak_entity(); let focus_handle = self.focus_handle.clone(); let table_focus_handle = @@ -3865,6 +3876,7 @@ impl Render for GitGraph { .hide_row_borders() .hide_row_hover() .width_config(table_width_config) + .column_filter(table_filter) .map_row(move |(index, row), window, cx| { let is_selected = selected_entry_idx == Some(index); let is_hovered = hovered_entry_idx == Some(index); @@ -3951,10 +3963,18 @@ impl Render for GitGraph { .child( h_flex() .size_full() - .when(!is_path_history, |this| { + .when(!is_path_history && graph_visible, |this| { this.child( div() - .w(DefiniteLength::Fraction(graph_fraction)) + .map(|this| { + if table_collapsed { + this.w(graph_content_width) + } else { + this.w(DefiniteLength::Fraction( + graph_fraction, + )) + } + }) .h_full() .min_w_0() .overflow_hidden() @@ -3966,7 +3986,15 @@ impl Render for GitGraph { .tab_index(2) .tab_group() .tab_stop(false) - .w(DefiniteLength::Fraction(table_fraction)) + .map(|this| { + if table_collapsed { + this.flex_1() + } else { + this.w(DefiniteLength::Fraction( + table_fraction, + )) + } + }) .h_full() .min_w_0() .child(commits_table), @@ -3974,10 +4002,12 @@ impl Render for GitGraph { ) .child(render_redistributable_columns_resize_handles( &self.column_widths, + Some(&self.column_visibility), window, cx, )), self.column_widths.clone(), + Some(self.column_visibility.clone()), ) }), ) @@ -4169,6 +4199,7 @@ impl workspace::SerializableItem for GitGraph { selected_sha, search_query, search_case_sensitive, + hidden_columns, )) = db.get_git_graph(item_id, workspace_id).ok().flatten() else { return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize"))); @@ -4181,6 +4212,7 @@ impl workspace::SerializableItem for GitGraph { selected_sha, search_query, search_case_sensitive, + hidden_columns, }; let window_handle = window.window_handle(); @@ -4223,6 +4255,16 @@ impl workspace::SerializableItem for GitGraph { }); git_graph.update(cx, |graph, cx| { + if let Some(bits) = state.hidden_columns { + let cols = graph.column_visibility.cols(); + let mask = persistence::deserialize_hidden_columns(bits, cols); + // Never restore an all-hidden mask (e.g. from corrupt data); the UI + // guarantees at least one column stays visible. + if mask.iter().any(|is_hidden| !is_hidden) { + graph.column_visibility = TableRow::from_vec(mask, cols); + } + } + graph.search_state.case_sensitive = state.search_case_sensitive.unwrap_or(false); @@ -4275,6 +4317,9 @@ impl workspace::SerializableItem for GitGraph { let log_source_value = persistence::serialize_log_source_value(&self.log_source); let log_order = Some(persistence::serialize_log_order(&self.log_order)); let search_case_sensitive = Some(self.search_state.case_sensitive); + let hidden_columns = Some(persistence::serialize_hidden_columns( + self.column_visibility.as_slice(), + )); let db = persistence::GitGraphsDb::global(cx); Some(cx.background_spawn(async move { @@ -4288,6 +4333,7 @@ impl workspace::SerializableItem for GitGraph { selected_sha, search_query, search_case_sensitive, + hidden_columns, ) .await })) @@ -4343,6 +4389,9 @@ mod persistence { ALTER TABLE git_graphs ADD COLUMN search_query TEXT; ALTER TABLE git_graphs ADD COLUMN search_case_sensitive INTEGER; ), + sql!( + ALTER TABLE git_graphs ADD COLUMN hidden_columns INTEGER; + ), ]; } @@ -4419,6 +4468,23 @@ mod persistence { } } + /// Packs the per-column visibility mask into a bitmask (bit `i` set means column `i` is + /// hidden), so it fits in a single integer database column regardless of column count. + pub fn serialize_hidden_columns(hidden: &[bool]) -> i32 { + hidden.iter().enumerate().fold( + 0, + |bits, (idx, &is_hidden)| { + if is_hidden { bits | (1 << idx) } else { bits } + }, + ) + } + + /// Inverse of [`serialize_hidden_columns`]. Bits beyond `cols` are ignored, and missing + /// bits default to visible, so a mask saved with a different column count degrades safely. + pub fn deserialize_hidden_columns(bits: i32, cols: usize) -> Vec { + (0..cols).map(|idx| bits & (1 << idx) != 0).collect() + } + #[derive(Debug, Default, Clone)] pub struct SerializedGitGraphState { pub log_source_type: Option, @@ -4427,6 +4493,7 @@ mod persistence { pub selected_sha: Option, pub search_query: Option, pub search_case_sensitive: Option, + pub hidden_columns: Option, } impl GitGraphsDb { @@ -4440,14 +4507,16 @@ mod persistence { log_order: Option, selected_sha: Option, search_query: Option, - search_case_sensitive: Option + search_case_sensitive: Option, + hidden_columns: Option ) -> Result<()> { INSERT OR REPLACE INTO git_graphs( item_id, workspace_id, repo_working_path, log_source_type, log_source_value, log_order, - selected_sha, search_query, search_case_sensitive + selected_sha, search_query, search_case_sensitive, + hidden_columns ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) } } @@ -4462,7 +4531,8 @@ mod persistence { Option, Option, Option, - Option + Option, + Option )>> { SELECT repo_working_path, @@ -4471,7 +4541,8 @@ mod persistence { log_order, selected_sha, search_query, - search_case_sensitive + search_case_sensitive, + hidden_columns FROM git_graphs WHERE item_id = ? AND workspace_id = ? } @@ -4628,7 +4699,9 @@ mod tests { use git::repository::{CommitData, InitialGraphCommitData}; use gpui::{TestAppContext, UpdateGlobal}; use project::git_store::{GitStoreEvent, RepositoryEvent}; - use project::{Project, TaskSourceKind, task_store::TaskSettingsLocation}; + use project::{ + GIT_COMMAND_TASK_TAG, Project, TaskSourceKind, task_store::TaskSettingsLocation, + }; use rand::prelude::*; use serde_json::json; use settings::{SettingsStore, ThemeSettingsContent}; @@ -5760,6 +5833,7 @@ mod tests { selected_sha: Some(sha.to_string()), search_query: Some("fix bug".to_string()), search_case_sensitive: Some(true), + hidden_columns: None, }; assert_eq!( @@ -5784,6 +5858,7 @@ mod tests { selected_sha: None, search_query: None, search_case_sensitive: None, + hidden_columns: None, }; assert_eq!( persistence::deserialize_log_source(&all_state), @@ -5825,6 +5900,34 @@ mod tests { )); } + #[gpui::test] + fn test_hidden_columns_bitmask_roundtrip(_cx: &mut TestAppContext) { + let mask = [false, true, false, true, false]; + let bits = persistence::serialize_hidden_columns(&mask); + assert_eq!( + persistence::deserialize_hidden_columns(bits, mask.len()), + mask.to_vec() + ); + + assert_eq!(persistence::serialize_hidden_columns(&[false; 5]), 0); + assert_eq!( + persistence::deserialize_hidden_columns(0, 4), + vec![false; 4] + ); + + // A mask saved with more columns than we restore with is truncated safely, and one + // saved with fewer columns defaults the extra columns to visible. + let bits = persistence::serialize_hidden_columns(&[true, false, true, false, true]); + assert_eq!( + persistence::deserialize_hidden_columns(bits, 4), + vec![true, false, true, false] + ); + assert_eq!( + persistence::deserialize_hidden_columns(bits, 6), + vec![true, false, true, false, true, false] + ); + } + #[gpui::test] async fn test_git_graph_state_persists_across_serialization_roundtrip(cx: &mut TestAppContext) { init_test(cx); @@ -5900,6 +6003,9 @@ mod tests { .await .expect("should create workspace id"); let db = cx.read(|cx| persistence::GitGraphsDb::global(cx)); + // Hide the "Date" column (index 2 in the non-path-history layout). + let hidden_columns = + persistence::serialize_hidden_columns(&[false, false, true, false, false]); db.save_git_graph( item_id, workspace_id, @@ -5910,6 +6016,7 @@ mod tests { selected_sha.clone(), Some("some query".to_string()), Some(true), + Some(hidden_columns), ) .await .expect("save should succeed"); @@ -5963,6 +6070,12 @@ mod tests { graph.search_state.case_sensitive, true, "search case sensitivity should be restored" ); + + assert_eq!( + graph.column_visibility.as_slice(), + &[false, false, true, false, false], + "hidden columns should be restored" + ); }); restored_graph.read_with(&*cx, |graph, cx| { @@ -6560,6 +6673,106 @@ mod tests { ); } + #[gpui::test] + async fn test_open_at_commit_reuses_loaded_graph(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA"); + let second_sha = Oid::from_bytes(&[2; 20]).expect("valid commit SHA"); + fs.set_graph_commits( + Path::new("/project/.git"), + vec![ + Arc::new(InitialGraphCommitData { + sha: second_sha, + parents: smallvec![first_sha], + ref_names: vec!["HEAD -> main".into()], + }), + Arc::new(InitialGraphCommitData { + sha: first_sha, + parents: smallvec![], + ref_names: Vec::new(), + }), + ], + ); + fs.set_commit_data( + Path::new("/project/.git"), + [first_sha, second_sha].map(|sha| { + ( + CommitData { + sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Commit subject".into(), + message: "Commit message".into(), + }, + false, + ) + }), + ); + + let project = Project::test(fs, [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone()); + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace.downgrade(), + None, + window, + cx, + ) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx); + }); + cx.run_until_parked(); + + git_graph.update(cx, |graph, cx| { + graph.select_commit_by_sha(first_sha, cx); + }); + cx.run_until_parked(); + git_graph.update(cx, |graph, cx| { + graph.select_commit_by_sha(second_sha, cx); + }); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + open_or_reuse_graph( + workspace, + repository.read(cx).id, + project.read(cx).git_store().clone(), + LogSource::All, + Some(first_sha.to_string()), + window, + cx, + ); + }); + cx.run_until_parked(); + + git_graph.read_with(&*cx, |graph, _| { + assert_eq!(graph.selected_entry_idx, Some(1)); + }); + } + #[gpui::test] async fn test_git_graph_navigation(cx: &mut TestAppContext) { init_test(cx); @@ -7032,4 +7245,306 @@ mod tests { ); assert_eq!(GitGraph::ref_name_from_decoration("HEAD"), None); } + + #[gpui::test] + async fn test_commit_message_rendered_as_markdown(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let commit_sha = Oid::from_bytes(&[1; 20]).unwrap(); + let commits = vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: smallvec![], + ref_names: vec!["HEAD -> main".into()], + })]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + fs.set_commit_data( + Path::new("/project/.git"), + [( + CommitData { + sha: commit_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Fix crash".into(), + message: "Fix crash\n\nThis fixes a crash that occurred when...".into(), + }, + false, + )], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + // Select the commit to trigger loading the commit message + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + // Verify the commit message was loaded as markdown + git_graph.read_with(&*cx, |graph, app| { + let message = graph + .selected_commit_message + .as_ref() + .expect("selected_commit_message should be Some"); + assert_eq!(message.sha, commit_sha); + let source = message.message.read_with(app, |m, _| m.source().to_owned()); + assert!(source.contains("Fix crash")); + assert!(source.contains("This fixes a crash")); + }); + } + + #[gpui::test] + async fn test_long_commit_message_is_constrained_to_scroll_viewport(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let commit_sha = Oid::from_bytes(&[1; 20]).expect("commit SHA should be valid"); + let commits = vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: smallvec![], + ref_names: vec!["HEAD -> main".into()], + })]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + + let message = (0..40) + .map(|line_number| { + format!( + "Line {line_number}: This commit message is long enough to require scrolling." + ) + }) + .collect::>() + .join("\n\n"); + fs.set_commit_data( + Path::new("/project/.git"), + [( + CommitData { + sha: commit_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Long commit message".into(), + message: message.into(), + }, + false, + )], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + git_graph.update(cx, |graph, cx| { + graph.selected_commit_diff = Some(CommitDiff { + files: vec![CommitFile { + path: RepoPath::new("file.txt").expect("repository path should be valid"), + old_text: Some("content".into()), + new_text: Some("updated content".into()), + is_binary: false, + }], + }); + graph.selected_commit_diff_stats = Some((1, 1)); + cx.notify(); + }); + + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); + cx.run_until_parked(); + + let (message_scroll_handle, changed_files_scroll_handle) = + git_graph.read_with(&*cx, |graph, _| { + ( + graph + .selected_commit_message + .as_ref() + .expect("selected commit message should be loaded") + .scroll_handle + .clone(), + graph.changed_files_scroll_handle.clone(), + ) + }); + let maximum_message_height = git_graph.update_in(cx, |_, window, cx| { + editor::hover_markdown_style(window, cx) + .base_text_style + .line_height_in_pixels(window.rem_size()) + * 12. + }); + let message_bounds = message_scroll_handle.bounds(); + let changed_files_bounds = changed_files_scroll_handle.0.borrow().base_handle.bounds(); + + assert!( + message_bounds.size.height <= maximum_message_height, + "commit message viewport height ({}) should not exceed its maximum ({})", + message_bounds.size.height, + maximum_message_height, + ); + assert!( + message_scroll_handle.max_offset().y > px(0.), + "long commit message should be scrollable" + ); + assert!( + message_bounds.bottom() <= changed_files_bounds.top(), + "commit message viewport {message_bounds:?} should not overlap changed files {changed_files_bounds:?}" + ); + } + + #[gpui::test] + async fn test_commit_message_not_reloaded_for_same_sha(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let commit_sha = Oid::from_bytes(&[1; 20]).unwrap(); + let commits = vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: smallvec![], + ref_names: vec!["HEAD -> main".into()], + })]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + fs.set_commit_data( + Path::new("/project/.git"), + [( + CommitData { + sha: commit_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Fix crash".into(), + message: "Fix crash\n\nBody text.".into(), + }, + false, + )], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + // Select the commit to load the message + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + // Verify message is loaded + let message_entity_id = git_graph.read_with(&*cx, |graph, _| { + graph + .selected_commit_message + .as_ref() + .map(|m| m.message.entity_id()) + }); + assert!(message_entity_id.is_some()); + + // Select the same commit again to trigger the early-return logic + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + // Verify the message entity is the same (not replaced) + git_graph.read_with(&*cx, |graph, _| { + let new_entity_id = graph + .selected_commit_message + .as_ref() + .map(|m| m.message.entity_id()); + assert_eq!(message_entity_id, new_entity_id); + }); + } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e1a6933ce62498..b7aff2d0118dcb 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,11 +1,16 @@ use crate::askpass_modal::AskPassModal; +use crate::commit_context_menu::{ + CommitContextMenuData, CommitContextMenuSource, commit_context_menu, +}; use crate::commit_modal::CommitModal; use crate::commit_tooltip::{CommitAvatar, CommitTooltip}; use crate::commit_view::CommitView; use crate::git_panel_settings::GitPanelScrollbarAccessor; -use crate::project_diff::{BranchDiff, Diff, ProjectDiff}; +use crate::project_diff::{DeployBranchDiff, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::solo_diff_view::SoloDiffView; +use crate::staged_diff::StagedDiff; +use crate::unstaged_diff::UnstagedDiff; use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, @@ -13,6 +18,7 @@ use crate::{ use agent_settings::{AgentSettings, UserAgentsMd}; use anyhow::Context as _; use askpass::AskPassDelegate; +use client::zed_urls; use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KeyValueStore; use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior}; @@ -24,8 +30,9 @@ use git::Oid; use git::commit::ParsedCommitMessage; use git::repository::{ Branch, CommitData, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, - GitCommitTemplate, GitCommitter, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, - ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, get_git_committer, + GitCommitTemplate, GitCommitter, InitialGraphCommitData, LogOrder, LogSource, PushOptions, + Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, + get_git_committer, }; use git::stash::GitStash; use git::status::{DiffStat, StageStatus}; @@ -36,10 +43,10 @@ use git::{ ViewFile, parse_git_remote_url, }; use gpui::{ - AbsoluteLength, Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent, - Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, - Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, TextStyle, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, uniform_list, + AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, ClickEvent, + DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, + MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, + TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, }; use itertools::Itertools; use language::{Buffer, BufferEvent, File}; @@ -77,8 +84,8 @@ use strum::{IntoEnumIterator, VariantNames}; use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, - IndentGuideColors, KeyBinding, PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, + ButtonLike, Checkbox, Chip, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, + IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::paths::PathStyle; @@ -89,12 +96,17 @@ use workspace::{ dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, }; -use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; +use zed_actions::{ + DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, git_panel::ToggleFocus, +}; const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); // TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel const TREE_INDENT: f32 = 16.0; +const MAX_HISTORY_TAG_CHIPS: usize = 3; +// Horizontal offset that aligns the tree indent guides with the row icon column. +const INDENT_GUIDE_LEFT_OFFSET: gpui::Pixels = gpui::px(19.); actions!( git_panel, @@ -103,8 +115,6 @@ actions!( Close, /// Toggles the git panel. Toggle, - /// Toggles focus on the git panel. - ToggleFocus, /// Opens the git panel menu. OpenMenu, /// Focuses on the commit message editor. @@ -129,12 +139,18 @@ actions!( SetGroupByNone, /// Groups entries by status. SetGroupByStatus, + /// Groups entries by staging state. + SetGroupByStaging, /// Toggles showing entries in tree vs flat view. ToggleTreeView, /// Expands the selected entry to show its children. ExpandSelectedEntry, /// Collapses the selected entry to hide its children. CollapseSelectedEntry, + /// View unstaged changes + ViewUnstagedChanges, + /// View staged changes + ViewStagedChanges, /// Activates the Changes tab. ActivateChangesTab, /// Activates the History tab. @@ -192,6 +208,11 @@ fn git_panel_context_menu( .context(focus_handle.clone()) .action_disabled_when(!has_unstaged_changes, "Stage All", StageAll.boxed_clone()) .action_disabled_when(!has_staged_changes, "Unstage All", UnstageAll.boxed_clone()) + .action_disabled_when( + !has_tracked_changes, + "Restore All Changes", + RestoreTrackedFiles.boxed_clone(), + ) .separator() .action_disabled_when( !(has_new_changes || has_tracked_changes), @@ -309,7 +330,7 @@ fn git_panel_view_options_menu( }) .item({ let view_options_menu_state = view_options_menu_state.clone(); - ContextMenuEntry::new("Status") + ContextMenuEntry::new("Tracked & Untracked") .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::Status) .handler(move |window, cx| { if state.group_by != GitPanelGroupBy::Status { @@ -321,6 +342,23 @@ fn git_panel_view_options_menu( } }) }) + .item({ + let view_options_menu_state = view_options_menu_state.clone(); + ContextMenuEntry::new("Staged & Unstaged") + .toggle( + IconPosition::End, + state.group_by == GitPanelGroupBy::Staging, + ) + .handler(move |window, cx| { + if state.group_by != GitPanelGroupBy::Staging { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + group_by: GitPanelGroupBy::Staging, + ..state + }); + window.dispatch_action(Box::new(SetGroupByStaging), cx); + } + }) + }) }) } @@ -395,11 +433,38 @@ enum GitPanelTab { History, } +#[derive(Debug, PartialEq, Eq, Clone)] +enum CommitHistory { + Loading, + /// A non-empty list can still grow on later fetches. + /// An empty list means the repository has no commits. + Loaded(Rc<[CommitHistoryEntry]>), + Error(SharedString), +} + +fn commit_history_from_response( + entries: Rc<[CommitHistoryEntry]>, + is_loading: bool, + error: Option, +) -> CommitHistory { + if !entries.is_empty() { + CommitHistory::Loaded(entries) + } else if let Some(error) = error { + CommitHistory::Error(error) + } else if is_loading { + CommitHistory::Loading + } else { + CommitHistory::Loaded(Rc::from([])) + } +} + #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] enum Section { Conflict, Tracked, New, + Staged, + Unstaged, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -407,6 +472,70 @@ struct GitHeaderEntry { header: Section, } +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +struct ProjectedChangeEntry { + section: Section, + index: usize, +} + +/// What clicking a staging control should do. +/// +/// In the "staged & unstaged" grouping, a partially staged file appears in both the +/// "Staged" and "Unstaged" sections at once, so a row's meaning comes from +/// the section it is rendered in rather than from the file's own state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StageIntent { + Stage, + Unstage, + Toggle, +} + +impl StageIntent { + fn for_section(section: Section) -> Self { + match section { + Section::Staged => StageIntent::Unstage, + Section::Unstaged => StageIntent::Stage, + _ => StageIntent::Toggle, + } + } + + /// Resolves to a concrete direction (`true` = stage), consulting the + /// current stage status only when no section dictates one. + fn resolve_with(self, stage_status: impl FnOnce() -> StageStatus) -> bool { + match self { + StageIntent::Stage => true, + StageIntent::Unstage => false, + StageIntent::Toggle => match stage_status() { + StageStatus::Staged => false, + StageStatus::Unstaged | StageStatus::PartiallyStaged => true, + }, + } + } + + fn checkbox_state(self, entry_state: impl FnOnce() -> ToggleState) -> ToggleState { + match self { + StageIntent::Stage => ToggleState::Unselected, + StageIntent::Unstage => ToggleState::Selected, + StageIntent::Toggle => entry_state(), + } + } + + fn label(self, stage_status: impl FnOnce() -> StageStatus) -> &'static str { + if self.resolve_with(stage_status) { + "Stage" + } else { + "Unstage" + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DiffTarget { + Uncommitted, + Staged, + Unstaged, +} + impl GitHeaderEntry { pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool { let this = &self.header; @@ -417,6 +546,18 @@ impl GitHeaderEntry { } Section::Tracked => !status.is_created(), Section::New => status.is_created(), + // Conflicted files render only under the Conflict section, so the + // Staged/Unstaged bulk operations must not sweep them up: "Unstage + // All" would silently un-resolve conflicts, and "Stage All" would + // silently mark them resolved. + Section::Staged => { + !repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) + && GitPanel::stage_status_for_entry(status_entry, repo).has_staged() + } + Section::Unstaged => { + !repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) + && GitPanel::stage_status_for_entry(status_entry, repo).has_unstaged() + } } } pub fn title(&self) -> &'static str { @@ -424,6 +565,8 @@ impl GitHeaderEntry { Section::Conflict => "Conflicts", Section::Tracked => "Tracked", Section::New => "Untracked", + Section::Staged => "Staged", + Section::Unstaged => "Unstaged", } } } @@ -434,6 +577,7 @@ enum GitListEntry { TreeStatus(GitTreeStatusEntry), Directory(GitTreeDirEntry), Header(GitHeaderEntry), + EmptySection(Section), } impl GitListEntry { @@ -460,6 +604,13 @@ impl GitListEntry { _ => 0, } } + + fn is_selectable(&self) -> bool { + matches!( + self, + GitListEntry::Status(_) | GitListEntry::TreeStatus(_) | GitListEntry::Directory(_) + ) + } } enum GitPanelViewMode { @@ -497,7 +648,7 @@ struct TreeViewState { // Length equals the number of visible entries. // This is needed because some entries (like collapsed directories) may be hidden. logical_indices: Vec, - expanded_dirs: HashMap, + expanded_dirs: HashMap, directory_descendants: HashMap>, } @@ -572,8 +723,8 @@ impl TreeViewState { let (child_flattened, mut child_statuses) = self.flatten_tree(terminal, section, depth + 1, seen_directories); let key = TreeKey { section, path }; - let expanded = *self.expanded_dirs.get(&key.path).unwrap_or(&true); - self.expanded_dirs.entry(key.path.clone()).or_insert(true); + let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); + self.expanded_dirs.entry(key.clone()).or_insert(true); seen_directories.insert(key.clone()); self.directory_descendants @@ -755,8 +906,8 @@ pub struct GitPanel { generate_commit_message_task: Option>>, entries: Vec, view_mode: GitPanelViewMode, - tree_expanded_dirs: HashMap, - entries_indices: HashMap, + tree_expanded_dirs: HashMap, + projected_entries_by_path: HashMap>, single_staged_entry: Option, single_tracked_entry: Option, focus_handle: FocusHandle, @@ -795,14 +946,16 @@ pub struct GitPanel { stash_entries: GitStash, active_tab: GitPanelTab, commit_history_scroll_handle: UniformListScrollHandle, - commit_history_shas: Option>, + commit_history: CommitHistory, focused_history_entry: Option, history_keyboard_nav: bool, _commit_message_buffer_subscription: Option, _repo_subscriptions: Vec, - _settings_subscription: Subscription, - git_access: GitAccess, + git_access: Option, + commit_menu_handle: PopoverMenuHandle, + changes_actions_menu_handle: PopoverMenuHandle, + remote_action_menu_handle: PopoverMenuHandle, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -811,6 +964,25 @@ struct BulkStaging { anchor: RepoPath, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct CommitHistoryEntry { + sha: Oid, + tag_names: Vec, +} + +impl From<&Arc> for CommitHistoryEntry { + fn from(commit: &Arc) -> Self { + Self { + sha: commit.sha, + tag_names: commit + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect(), + } + } +} + const MAX_PANEL_EDITOR_LINES: usize = 6; pub(crate) fn commit_message_editor( @@ -1003,10 +1175,18 @@ impl GitPanel { ) | GitStoreEvent::RepositoryAdded | GitStoreEvent::RepositoryRemoved(_) - | GitStoreEvent::GlobalConfigurationUpdated | GitStoreEvent::ActiveRepositoryChanged(_) => { this.schedule_update(window, cx); } + GitStoreEvent::RepositoryUpdated( + _, + RepositoryEvent::GitDirectoryChanged, + true, + ) + | GitStoreEvent::GlobalConfigurationUpdated => { + this.git_access = None; + this.schedule_update(window, cx); + } GitStoreEvent::IndexWriteError(error) => { this.workspace .update(cx, |workspace, cx| { @@ -1031,7 +1211,7 @@ impl GitPanel { entries: Vec::new(), view_mode: GitPanelViewMode::from_settings(cx), tree_expanded_dirs: HashMap::default(), - entries_indices: HashMap::default(), + projected_entries_by_path: HashMap::default(), focus_handle: cx.focus_handle(), fs, new_count: 0, @@ -1068,13 +1248,16 @@ impl GitPanel { stash_entries: Default::default(), active_tab: GitPanelTab::Changes, commit_history_scroll_handle: UniformListScrollHandle::new(), - commit_history_shas: None, + commit_history: CommitHistory::Loading, focused_history_entry: None, history_keyboard_nav: false, _commit_message_buffer_subscription: None, _repo_subscriptions: Vec::new(), _settings_subscription, - git_access: GitAccess::Yes, + git_access: None, + commit_menu_handle: PopoverMenuHandle::default(), + changes_actions_menu_handle: PopoverMenuHandle::default(), + remote_action_menu_handle: PopoverMenuHandle::default(), }; this.schedule_update(window, cx); @@ -1083,7 +1266,18 @@ impl GitPanel { } pub fn entry_by_path(&self, path: &RepoPath) -> Option { - self.entries_indices.get(path).copied() + self.projected_entries_by_path + .get(path)? + .first() + .map(|entry| entry.index) + } + + fn entry_by_path_in_section(&self, path: &RepoPath, section: Section) -> Option { + self.projected_entries_by_path + .get(path)? + .iter() + .find(|entry| entry.section == section) + .map(|entry| entry.index) } pub fn select_entry_by_path( @@ -1096,7 +1290,7 @@ impl GitPanel { return; }; - let (repo_path, section) = { + let (repo_path, default_section) = { let repo = git_repo.read(cx); let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else { return; @@ -1106,7 +1300,15 @@ impl GitPanel { .status_for_path(&repo_path) .map(|status| status.status) .map(|status| { - if repo.had_conflict_on_last_merge_head_change(&repo_path) { + if GitPanelSettings::get_global(cx).group_by == GitPanelGroupBy::Staging { + if repo.had_conflict_on_last_merge_head_change(&repo_path) { + Section::Conflict + } else if status.staging().has_staged() { + Section::Staged + } else { + Section::Unstaged + } + } else if repo.had_conflict_on_last_merge_head_change(&repo_path) { Section::Conflict } else if status.is_created() { Section::New @@ -1117,6 +1319,15 @@ impl GitPanel { (repo_path, section) }; + let selected_section = self.selected_entry.and_then(|index| { + let selected_entry = self.entries.get(index)?.status_entry()?; + if selected_entry.repo_path == repo_path { + self.section_for_entry_index(index) + } else { + None + } + }); + let section = selected_section.or(default_section); let mut needs_rebuild = false; if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) { @@ -1127,8 +1338,8 @@ impl GitPanel { path: RepoPath::from_rel_path(dir), }; - if tree_state.expanded_dirs.get(&key.path) == Some(&false) { - tree_state.expanded_dirs.insert(key.path.clone(), true); + if tree_state.expanded_dirs.get(&key) == Some(&false) { + tree_state.expanded_dirs.insert(key, true); needs_rebuild = true; } @@ -1140,7 +1351,10 @@ impl GitPanel { self.update_visible_entries(window, cx); } - let Some(ix) = self.entry_by_path(&repo_path) else { + let Some(ix) = section + .and_then(|section| self.entry_by_path_in_section(&repo_path, section)) + .or_else(|| self.entry_by_path(&repo_path)) + else { return; }; @@ -1414,35 +1628,42 @@ impl GitPanel { return; } - if matches!( - self.entries.get(new_index.saturating_sub(1)), - Some(GitListEntry::Header(..)) - ) && new_index == 0 - { - return; - } - - if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) { - self.selected_entry = match &self.view_mode { - GitPanelViewMode::Flat => Some(new_index.saturating_sub(1)), - GitPanelViewMode::Tree(tree_view_state) => { - maybe!({ - let current_logical_index = tree_view_state - .logical_indices - .iter() - .position(|&i| i == new_index)?; - - tree_view_state - .logical_indices - .get(current_logical_index.saturating_sub(1)) - .copied() - }) + let candidate = match &self.view_mode { + GitPanelViewMode::Flat => { + let mut candidate = new_index; + loop { + match self.entries.get(candidate) { + Some(entry) if entry.is_selectable() => break Some(candidate), + Some(_) => { + if candidate == 0 { + break None; + } + candidate -= 1; + } + None => break None, + } } - }; - } else { - self.selected_entry = Some(new_index); - } + } + GitPanelViewMode::Tree(state) => { + let mut position = state.logical_indices.iter().position(|&i| i == new_index); + loop { + let Some(current) = position else { break None }; + let Some(&index) = state.logical_indices.get(current) else { + break None; + }; + match self.entries.get(index) { + Some(entry) if entry.is_selectable() => break Some(index), + _ => position = current.checked_sub(1), + } + } + } + }; + + let Some(candidate) = candidate else { + return; + }; + self.selected_entry = Some(candidate); self.scroll_to_selected_entry(cx); } @@ -1490,18 +1711,54 @@ impl GitPanel { } }; - if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) { - self.selected_entry = Some(new_index.saturating_add(1)); - } else { - self.selected_entry = Some(new_index); - } + let candidate = match &self.view_mode { + GitPanelViewMode::Flat => { + let mut candidate = new_index; + loop { + match self.entries.get(candidate) { + Some(entry) if entry.is_selectable() => break Some(candidate), + Some(_) => candidate += 1, + None => break None, + } + } + } + GitPanelViewMode::Tree(state) => { + let mut position = state.logical_indices.iter().position(|&i| i == new_index); + loop { + let Some(current) = position else { break None }; + let Some(&index) = state.logical_indices.get(current) else { + break None; + }; + match self.entries.get(index) { + Some(entry) if entry.is_selectable() => break Some(index), + _ => position = Some(current + 1), + } + } + } + }; + + let Some(candidate) = candidate else { + return; + }; + self.selected_entry = Some(candidate); self.scroll_to_selected_entry(cx); } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - if self.entries.last().is_some() { - self.selected_entry = Some(self.entries.len() - 1); + let last_entry = match &self.view_mode { + GitPanelViewMode::Flat => self.entries.iter().rposition(GitListEntry::is_selectable), + GitPanelViewMode::Tree(state) => { + state.logical_indices.iter().rev().copied().find(|&index| { + self.entries + .get(index) + .is_some_and(GitListEntry::is_selectable) + }) + } + }; + + if let Some(last_entry) = last_entry { + self.selected_entry = Some(last_entry); self.scroll_to_selected_entry(cx); } } @@ -1510,13 +1767,34 @@ impl GitPanel { fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context) { maybe!({ let workspace = self.workspace.upgrade()?; - - if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) { - let entry = self.entries.get(self.selected_entry?)?.status_entry()?; - - project_diff.update(cx, |project_diff, cx| { - project_diff.move_to_entry(entry.clone(), window, cx); - }); + let selected_index = self.selected_entry?; + let entry = self.entries.get(selected_index)?.status_entry()?.clone(); + let target = + Self::diff_target_for_section(self.section_for_entry_index(selected_index)); + + match target { + DiffTarget::Staged => { + if let Some(staged_diff) = workspace.read(cx).item_of_type::(cx) { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.move_to_entry(entry, window, cx); + }); + } + } + DiffTarget::Unstaged => { + if let Some(unstaged_diff) = workspace.read(cx).item_of_type::(cx) + { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.move_to_entry(entry, window, cx); + }); + } + } + DiffTarget::Uncommitted => { + if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) { + project_diff.update(cx, |project_diff, cx| { + project_diff.move_to_entry(entry, window, cx); + }); + } + } } Some(()) @@ -1582,6 +1860,14 @@ impl GitPanel { self.selected_entry.and_then(|i| self.entries.get(i)) } + fn change_entries_by_path(&self) -> impl Iterator { + // A grouping can project one changed file into multiple list rows. + self.entries + .iter() + .filter_map(GitListEntry::status_entry) + .unique_by(|entry| entry.repo_path.clone()) + } + fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { if self.active_tab == GitPanelTab::History { self.open_selected_history_commit(window, cx); @@ -1596,11 +1882,15 @@ impl GitPanel { return; } maybe!({ - let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + let selected_index = self.selected_entry?; + let entry = self.entries.get(selected_index)?.status_entry()?; let workspace = self.workspace.upgrade()?; let git_repo = self.active_repository.as_ref()?; + let target = + Self::diff_target_for_section(self.section_for_entry_index(selected_index)); - if let Some(project_diff) = workspace.read(cx).active_item_as::(cx) + if target == DiffTarget::Uncommitted + && let Some(project_diff) = workspace.read(cx).active_item_as::(cx) && let Some(project_path) = project_diff.read(cx).active_project_path(cx) && Some(&entry.repo_path) == git_repo @@ -1614,8 +1904,16 @@ impl GitPanel { }; self.workspace - .update(cx, |workspace, cx| { - ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + .update(cx, |workspace, cx| match target { + DiffTarget::Uncommitted => { + ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + } + DiffTarget::Staged => { + StagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + } + DiffTarget::Unstaged => { + UnstagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + } }) .ok(); self.focus_handle.focus(window, cx); @@ -1860,7 +2158,7 @@ impl GitPanel { let task = workspace.update(cx, |workspace, cx| { workspace .project() - .update(cx, |project, cx| project.delete_file(path, true, cx)) + .update(cx, |project, cx| project.trash_file(path, cx)) })?; if let Some(task) = task { task.await?; @@ -1960,10 +2258,9 @@ impl GitPanel { cx: &mut Context, ) { let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry().cloned()) + .change_entries_by_path() .filter(|status_entry| !status_entry.status.is_created()) + .cloned() .collect::>(); match entries.len() { @@ -2010,9 +2307,7 @@ impl GitPanel { return; }; let to_delete = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) + .change_entries_by_path() .filter(|status_entry| status_entry.status.is_created()) .cloned() .collect::>(); @@ -2054,7 +2349,7 @@ impl GitPanel { let project_path = active_repo .read(cx) .repo_path_to_project_path(&entry.repo_path, cx)?; - project.delete_file(project_path, true, cx) + project.trash_file(project_path, cx) }) }) .collect::>() @@ -2193,6 +2488,7 @@ impl GitPanel { fn toggle_staged_for_entry( &mut self, entry: &GitListEntry, + intent: StageIntent, _window: &mut Window, cx: &mut Context, ) { @@ -2205,48 +2501,32 @@ impl GitPanel { let (stage, repo_paths) = { let repo = active_repository.read(cx); match entry { - GitListEntry::Status(status_entry) => { + GitListEntry::Status(status_entry) + | GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: status_entry, + .. + }) => { let repo_paths = vec![status_entry.clone()]; - let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) { - StageStatus::Staged => { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.repo_path - { - clear_anchor = Some(op.anchor); - } - false - } - StageStatus::Unstaged | StageStatus::PartiallyStaged => { - set_anchor = Some(status_entry.repo_path.clone()); - true - } - }; - (stage, repo_paths) - } - GitListEntry::TreeStatus(status_entry) => { - let repo_paths = vec![status_entry.entry.clone()]; - let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) { - StageStatus::Staged => { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.entry.repo_path - { - clear_anchor = Some(op.anchor); - } - false - } - StageStatus::Unstaged | StageStatus::PartiallyStaged => { - set_anchor = Some(status_entry.entry.repo_path.clone()); - true - } - }; + let stage = intent + .resolve_with(|| GitPanel::stage_status_for_entry(status_entry, &repo)); + + if stage { + set_anchor = Some(status_entry.repo_path.clone()); + } else if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.repo_path + { + clear_anchor = Some(op.anchor); + } (stage, repo_paths) } GitListEntry::Header(section) => { - let goal_staged_state = !self.header_state(section.header).selected(); + let goal_staged_state = match intent { + StageIntent::Stage => true, + StageIntent::Unstage => false, + StageIntent::Toggle => !self.header_state(section.header).selected(), + }; let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) + .change_entries_by_path() .filter(|status_entry| { section.contains(status_entry, &repo) && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool() @@ -2258,11 +2538,13 @@ impl GitPanel { (goal_staged_state, entries) } GitListEntry::Directory(entry) => { - let goal_staged_state = match self.stage_status_for_directory(entry, repo) { - StageStatus::Staged => StageStatus::Unstaged, - StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged, + let goal_stage = + intent.resolve_with(|| self.stage_status_for_directory(entry, repo)); + let goal_staged_state = if goal_stage { + StageStatus::Staged + } else { + StageStatus::Unstaged }; - let goal_stage = goal_staged_state == StageStatus::Staged; let entries = self .view_mode @@ -2278,6 +2560,7 @@ impl GitPanel { .collect::>(); (goal_stage, entries) } + GitListEntry::EmptySection(_) => return, } }; if let Some(anchor) = clear_anchor { @@ -2311,6 +2594,7 @@ impl GitPanel { let repo_paths = entries .iter() .map(|entry| entry.repo_path.clone()) + .unique() .collect(); if stage { repo.stage_entries(repo_paths, cx) @@ -2424,16 +2708,27 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { - if let Some(selected_entry) = self.get_selected_entry().cloned() { - self.toggle_staged_for_entry(&selected_entry, window, cx); + let Some(selected_index) = self.selected_entry else { + return; + }; + let Some(selected_entry) = self.entries.get(selected_index).cloned() else { + return; + }; + + if self.is_resolved_conflict(selected_index, cx) { + return; } + + let intent = self.stage_intent_for_entry_index(selected_index); + self.toggle_staged_for_entry(&selected_entry, intent, window, cx); } fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context) { let Some(index) = self.selected_entry else { return; }; - self.stage_bulk(index, cx); + let stage = self.stage_intent_for_entry_index(index) != StageIntent::Unstage; + self.stage_bulk(index, stage, cx); } fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context) { @@ -2664,9 +2959,7 @@ impl GitPanel { cx.background_spawn(async move { commit_task.await? }) } else { let changed_files = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) + .change_entries_by_path() .filter(|status_entry| !status_entry.status.is_created()) .map(|status_entry| status_entry.repo_path.clone()) .collect::>(); @@ -2947,7 +3240,7 @@ impl GitPanel { let worktree_snapshot = worktree.read(cx).snapshot(); for rules_name in RULES_FILE_NAMES { - if let Ok(rel_path) = RelPath::unix(rules_name) { + if let Ok(rel_path) = RelPath::from_unix_str(rules_name) { if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) { if entry.is_file() { return Some(ProjectPath { @@ -3876,6 +4169,58 @@ impl GitPanel { } } + fn view_staged_changes( + &mut self, + _: &ViewStagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + let entry = self + .get_selected_entry() + .and_then(|entry| entry.status_entry()) + .cloned(); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + StagedDiff::deploy_at(workspace, entry, window, cx); + }); + } + } + + fn view_unstaged_changes( + &mut self, + _: &ViewUnstagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + let entry = self + .get_selected_entry() + .and_then(|entry| entry.status_entry()) + .cloned(); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + UnstagedDiff::deploy_at(workspace, entry, window, cx); + }); + } + } + + fn set_group_by_staging( + &mut self, + _: &SetGroupByStaging, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }); + }); + } + } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { let current_setting = GitPanelSettings::get_global(cx).tree_view; if let Some(workspace) = self.workspace.upgrade() { @@ -3939,7 +4284,7 @@ impl GitPanel { fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) { if let Some(state) = self.view_mode.tree_state_mut() { - let expanded = state.expanded_dirs.entry(key.path.clone()).or_insert(true); + let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true); *expanded = !*expanded; self.tree_expanded_dirs = state.expanded_dirs.clone(); self.update_visible_entries(window, cx); @@ -4001,13 +4346,20 @@ impl GitPanel { let new_active_repository = self.project.read(cx).active_repository(cx); let active_repository_changed = self.active_repository.as_ref().map(Entity::entity_id) != new_active_repository.as_ref().map(Entity::entity_id); - if active_repository_changed && self.amend_pending { - // Leaving a repository with a pending amend: undo it so the amend - // state doesn't carry over to the newly active repository. The - // commit editor still holds the previous repository's buffer here - // (`reopen_commit_buffer` swaps it asynchronously below), so this - // restores the pre-amend draft into that repository's buffer. - self.set_amend_pending(false, cx); + if active_repository_changed { + if self.amend_pending { + // Leaving a repository with a pending amend: undo it so the amend + // state doesn't carry over to the newly active repository. The + // commit editor still holds the previous repository's buffer here + // (`reopen_commit_buffer` swaps it asynchronously below), so this + // restores the pre-amend draft into that repository's buffer. + self.set_amend_pending(false, cx); + } + self.git_access = None; + self._repo_subscriptions.clear(); + if self.active_tab == GitPanelTab::History { + self.set_commit_history(CommitHistory::Loading, cx); + } } self.active_repository = new_active_repository; self.reopen_commit_buffer(window, cx); @@ -4124,14 +4476,17 @@ impl GitPanel { fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context) { let path_style = self.project.read(cx).path_style(cx); + let selected_change = self.selected_entry.and_then(|index| { + let entry = self.entries.get(index)?.status_entry()?; + Some((entry.repo_path.clone(), self.section_for_entry_index(index))) + }); let bulk_staging = self.bulk_staging.take(); let last_staged_path_prev_index = bulk_staging .as_ref() .and_then(|op| self.entry_by_path(&op.anchor)); - self.active_repository = self.project.read(cx).active_repository(cx); self.entries.clear(); - self.entries_indices.clear(); + self.projected_entries_by_path.clear(); self.single_staged_entry.take(); self.single_tracked_entry.take(); self.conflicted_count = 0; @@ -4144,41 +4499,47 @@ impl GitPanel { self.tracked_staged_count = 0; self.entry_count = 0; self.max_width_item_index = None; - self.git_access = GitAccess::Yes; let settings = GitPanelSettings::get_global(cx); let sort_by = settings.sort_by; - let group_by_status = settings.group_by == GitPanelGroupBy::Status; + let group_by = settings.group_by; + let group_by_file_status = group_by == GitPanelGroupBy::Status; + let group_by_staging_state = group_by == GitPanelGroupBy::Staging; let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); if let Some(active_repo) = self.active_repository.as_ref() { - let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx)); - - cx.spawn_in(window, async move |git_panel, cx| { - // When the user does not own the `.git` folder, the - // `GitStore.spawn_local_git_worker` will fail to create the - // receiver for Git jobs, so this access check will be - // cancelled. - // - // We assume `GitAccess::No` on cancellation. I believe this is - // imprecise, other failures could also cause cancellation, but - // the consequence is just showing the "unsafe repo" UI, which - // seems acceptable for this edge case. - let access = match access.await { - Ok(access) => access, - Err(Canceled) => GitAccess::No, - }; + if self.git_access.is_none() { + let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx)); + + cx.spawn_in(window, async move |git_panel, cx| { + // When the user does not own the `.git` folder, the + // `GitStore.spawn_local_git_worker` will fail to create the + // receiver for Git jobs, so this access check will be + // cancelled. + // + // We assume `GitAccess::No` on cancellation. I believe this is + // imprecise, other failures could also cause cancellation, but + // the consequence is just showing the "unsafe repo" UI, which + // seems acceptable for this edge case. + let access = match access.await { + Ok(access) => access, + Err(Canceled) => GitAccess::No, + }; - git_panel.update(cx, |this, _cx| { - this.git_access = access; + git_panel.update(cx, |this, _cx| { + this.git_access = Some(access); + }) }) - }) - .detach_and_log_err(cx); + .detach_and_log_err(cx); + } } let mut changed_entries = Vec::new(); let mut new_entries = Vec::new(); let mut conflict_entries = Vec::new(); + let mut staged_entries = Vec::new(); + let mut unstaged_entries = Vec::new(); + let mut tracked_entries = Vec::new(); let mut single_staged_entry = None; let mut staged_count = 0; let mut seen_directories = HashSet::default(); @@ -4195,13 +4556,13 @@ impl GitPanel { self.stash_entries = repo.cached_stash(); - for entry in repo.cached_status() { + for status_entry in repo.cached_status() { self.changes_count += 1; - let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path); - let is_new = entry.status.is_created(); - let staging = entry.status.staging(); + let is_conflict = repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path); + let is_new = status_entry.status.is_created(); + let staging = status_entry.status.staging(); - if let Some(pending) = repo.pending_ops_for_path(&entry.repo_path) + if let Some(pending) = repo.pending_ops_for_path(&status_entry.repo_path) && pending .ops .iter() @@ -4211,20 +4572,39 @@ impl GitPanel { } let entry = GitStatusEntry { - repo_path: entry.repo_path.clone(), - status: entry.status, + repo_path: status_entry.repo_path.clone(), + status: status_entry.status, staging, - diff_stat: entry.diff_stat, + diff_stat: status_entry.diff_stat, }; + if !is_conflict && !is_new { + tracked_entries.push(entry.clone()); + } + if staging.has_staged() { staged_count += 1; single_staged_entry = Some(entry.clone()); } - if group_by_status && is_conflict { + if group_by_staging_state && is_conflict { + conflict_entries.push(entry); + } else if group_by_staging_state { + if staging.has_staged() { + staged_entries.push(GitStatusEntry { + diff_stat: status_entry.staged_diff_stat, + ..entry.clone() + }); + } + if staging.has_unstaged() { + unstaged_entries.push(GitStatusEntry { + diff_stat: status_entry.unstaged_diff_stat, + ..entry + }); + } + } else if group_by_file_status && is_conflict { conflict_entries.push(entry); - } else if group_by_status && is_new { + } else if group_by_file_status && is_new { new_entries.push(entry); } else { changed_entries.push(entry); @@ -4256,8 +4636,8 @@ impl GitPanel { } } - if conflict_entries.is_empty() && changed_entries.len() == 1 { - self.single_tracked_entry = changed_entries.first().cloned(); + if tracked_entries.len() == 1 { + self.single_tracked_entry = tracked_entries.pop(); } if !is_tree_view { @@ -4274,11 +4654,14 @@ impl GitPanel { sort_entries(&mut conflict_entries); sort_entries(&mut changed_entries); sort_entries(&mut new_entries); + sort_entries(&mut staged_entries); + sort_entries(&mut unstaged_entries); } let mut push_entry = |this: &mut Self, entry: GitListEntry, + section: Section, is_visible: bool, logical_indices: Option<&mut Vec>| { if let Some(estimate) = @@ -4292,7 +4675,13 @@ impl GitPanel { if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone()) { - this.entries_indices.insert(repo_path, this.entries.len()); + this.projected_entries_by_path + .entry(repo_path) + .or_default() + .push(ProjectedChangeEntry { + section, + index: this.entries.len(), + }); } if let (Some(indices), true) = (logical_indices, is_visible) { @@ -4302,15 +4691,30 @@ impl GitPanel { this.entries.push(entry); }; - macro_rules! take_section_entries { - () => { - [ - (Section::Conflict, std::mem::take(&mut conflict_entries)), - (Section::Tracked, std::mem::take(&mut changed_entries)), - (Section::New, std::mem::take(&mut new_entries)), - ] - }; - } + let section_entries = if group_by_staging_state { + vec![ + (Section::Conflict, std::mem::take(&mut conflict_entries)), + (Section::Staged, std::mem::take(&mut staged_entries)), + (Section::Unstaged, std::mem::take(&mut unstaged_entries)), + ] + } else { + vec![ + (Section::Conflict, std::mem::take(&mut conflict_entries)), + (Section::Tracked, std::mem::take(&mut changed_entries)), + (Section::New, std::mem::take(&mut new_entries)), + ] + }; + + // Keep Staged/Unstaged headers pinned even when empty (as long as there's + // anything to show at all) so the layout stays stable while staging. + let has_any_section_entries = section_entries + .iter() + .any(|(_, entries)| !entries.is_empty()); + let show_when_empty = |section: Section| { + group_by_staging_state + && has_any_section_entries + && matches!(section, Section::Staged | Section::Unstaged) + }; match &mut self.view_mode { GitPanelViewMode::Tree(tree_state) => { @@ -4321,15 +4725,26 @@ impl GitPanel { // because push_entry mutably borrows self let mut tree_state = std::mem::take(tree_state); - for (section, entries) in take_section_entries!() { - if entries.is_empty() { + for (section, entries) in section_entries { + if entries.is_empty() && !show_when_empty(section) { continue; } - if section != Section::Tracked || group_by_status { + if section != Section::Tracked || group_by != GitPanelGroupBy::None { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), + section, + true, + Some(&mut tree_state.logical_indices), + ); + } + + if entries.is_empty() { + push_entry( + self, + GitListEntry::EmptySection(section), + section, true, Some(&mut tree_state.logical_indices), ); @@ -4341,39 +4756,47 @@ impl GitPanel { push_entry( self, entry, + section, is_visible, Some(&mut tree_state.logical_indices), ); } } - let seen_directory_paths = seen_directories - .iter() - .map(|directory| directory.path.clone()) - .collect::>(); tree_state .expanded_dirs - .retain(|path, _| seen_directory_paths.contains(path)); + .retain(|key, _| seen_directories.contains(key)); self.tree_expanded_dirs = tree_state.expanded_dirs.clone(); self.view_mode = GitPanelViewMode::Tree(tree_state); } GitPanelViewMode::Flat => { - for (section, entries) in take_section_entries!() { - if entries.is_empty() { + for (section, entries) in section_entries { + if entries.is_empty() && !show_when_empty(section) { continue; } - if section != Section::Tracked || group_by_status { + if section != Section::Tracked || group_by != GitPanelGroupBy::None { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), + section, + true, + None, + ); + } + + if entries.is_empty() { + push_entry( + self, + GitListEntry::EmptySection(section), + section, true, None, ); } for entry in entries { - push_entry(self, GitListEntry::Status(entry), true, None); + push_entry(self, GitListEntry::Status(entry), section, true, None); } } } @@ -4398,6 +4821,11 @@ impl GitPanel { self.bulk_staging = bulk_staging; } + if let Some((path, section)) = selected_change { + self.selected_entry = section + .and_then(|section| self.entry_by_path_in_section(&path, section)) + .or_else(|| self.entry_by_path(&path)); + } self.select_first_entry_if_none(window, cx); self.select_last_entry_if_out_of_bounds(window, cx); @@ -4416,6 +4844,8 @@ impl GitPanel { Section::New => (self.new_staged_count, self.new_count), Section::Tracked => (self.tracked_staged_count, self.tracked_count), Section::Conflict => (self.conflicted_staged_count, self.conflicted_count), + Section::Staged => (self.entry_count, self.entry_count), + Section::Unstaged => (0, self.entry_count), }; if staged_count == 0 { ToggleState::Unselected @@ -4426,6 +4856,55 @@ impl GitPanel { } } + fn section_for_entry_index(&self, ix: usize) -> Option
{ + self.entries.get(..=ix)?.iter().rev().find_map(|entry| { + if let GitListEntry::Header(header) = entry { + Some(header.header) + } else { + None + } + }) + } + + fn stage_intent_for_entry_index(&self, ix: usize) -> StageIntent { + self.section_for_entry_index(ix) + .map_or(StageIntent::Toggle, StageIntent::for_section) + } + + // A conflict that has been marked resolved (fully staged) is locked + // against toggling: unstaging would rebuild the index entry from HEAD, + // silently discarding the unmerged (base/ours/theirs) stages — a + // round-trip git can't actually perform. The explicit git::UnstageFile + // action remains as an escape hatch. + fn is_resolved_conflict(&self, ix: usize, cx: &App) -> bool { + if self.section_for_entry_index(ix) != Some(Section::Conflict) { + return false; + } + let Some(entry) = self.entries.get(ix) else { + return false; + }; + let Some(repo) = self.active_repository.as_ref() else { + return false; + }; + let repo = repo.read(cx); + match entry { + GitListEntry::Directory(directory) => { + self.stage_status_for_directory(directory, repo) == StageStatus::Staged + } + entry => entry.status_entry().is_some_and(|status_entry| { + GitPanel::stage_status_for_entry(status_entry, repo) == StageStatus::Staged + }), + } + } + + fn diff_target_for_section(section: Option
) -> DiffTarget { + match section { + Some(Section::Staged) => DiffTarget::Staged, + Some(Section::Unstaged) => DiffTarget::Unstaged, + _ => DiffTarget::Uncommitted, + } + } + fn update_counts(&mut self, repo: &Repository) { self.show_placeholders = false; self.conflicted_count = 0; @@ -4437,7 +4916,8 @@ impl GitPanel { self.entry_count = 0; self.diff_stat_total = DiffStat::default(); - for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { + let change_entries = self.change_entries_by_path().cloned().collect::>(); + for status_entry in change_entries { self.entry_count += 1; if let Some(diff_stat) = status_entry.diff_stat { self.diff_stat_total.added = @@ -4448,23 +4928,21 @@ impl GitPanel { .saturating_add(diff_stat.deleted); } - let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo) - .as_bool() - .unwrap_or(true); + let stage_status = GitPanel::stage_status_for_entry(&status_entry, repo); if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.conflicted_staged_count += 1; } } else if status_entry.status.is_created() { self.new_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.new_staged_count += 1; } } else { self.tracked_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.tracked_staged_count += 1; } } @@ -4478,9 +4956,12 @@ impl GitPanel { } pub(crate) fn has_unstaged_changes(&self) -> bool { - self.tracked_count > self.tracked_staged_count - || self.new_count > self.new_staged_count - || self.conflicted_count > self.conflicted_staged_count + self.change_entries_by_path() + .any(|entry| entry.staging.has_unstaged()) + } + + fn primary_changes_action_stages(&self) -> bool { + self.entry_count == 0 || self.has_unstaged_changes() } fn has_tracked_changes(&self) -> bool { @@ -4488,7 +4969,8 @@ impl GitPanel { } pub fn has_unstaged_conflicts(&self) -> bool { - self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count + self.change_entries_by_path() + .any(|entry| entry.status.is_conflicted() && entry.staging.has_unstaged()) } fn show_error_toast(&self, action: impl Into, e: anyhow::Error, cx: &mut App) { @@ -4623,7 +5105,14 @@ impl GitPanel { .color(Color::Muted), ); match (style, is_push) { + (PushPrLink { label, url }, _) => { + this.action(label, move |_window, cx| cx.open_url(&url)) + } (Toast | ToastWithLog { .. }, true) => { + // If we were not able to parse a valid URL from the + // output of a push command, we'll simply dispatch the + // generic `CreatePullRequest` action when the toast + // button is pressed. this.action("Create Pull Request", move |window, cx| { window .dispatch_action(Box::new(zed_actions::git::CreatePullRequest), cx); @@ -4715,7 +5204,7 @@ impl GitPanel { GitListEntry::Directory(dir) => { Some(Self::item_width_estimate(0, dir.name.len(), dir.depth)) } - GitListEntry::Header(_) => None, + GitListEntry::Header(_) | GitListEntry::EmptySection(_) => None, } } @@ -4728,7 +5217,7 @@ impl GitPanel { PopoverMenu::new(id.into()) .trigger_with_tooltip( - IconButton::new("view-options-menu-trigger", IconName::Sliders) + IconButton::new("view-options-menu-trigger", IconName::Filter) .icon_size(IconSize::Small), Tooltip::text("View Options"), ) @@ -4782,34 +5271,38 @@ impl GitPanel { let editor_focus_handle = self.commit_editor.focus_handle(cx); - Some( - IconButton::new("generate-commit-message", IconName::AiEdit) - .shape(ui::IconButtonShape::Square) - .icon_color(if has_commit_model_configuration_error { - Color::Disabled + let button = IconButton::new("generate-commit-message", IconName::AiEdit) + .shape(ui::IconButtonShape::Square) + .icon_color(if has_commit_model_configuration_error { + Color::Disabled + } else { + Color::Muted + }) + .disabled(!can_commit || has_commit_model_configuration_error) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.generate_commit_message(cx); + })); + + let button = if can_commit && has_commit_model_configuration_error { + button.hoverable_tooltip(move |_window, cx| { + cx.new(|_| GenerateCommitMessageConfigurationTooltip).into() + }) + } else { + button.tooltip(move |_window, cx| { + if !can_commit { + Tooltip::simple("No Changes to Commit", cx) } else { - Color::Muted - }) - .tooltip(move |_window, cx| { - if !can_commit { - Tooltip::simple("No Changes to Commit", cx) - } else if has_commit_model_configuration_error { - Tooltip::simple("Configure an LLM provider to generate commit messages", cx) - } else { - Tooltip::for_action_in( - "Generate Commit Message", - &git::GenerateCommitMessage, - &editor_focus_handle, - cx, - ) - } - }) - .disabled(!can_commit || has_commit_model_configuration_error) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.generate_commit_message(cx); - })) - .into_any_element(), - ) + Tooltip::for_action_in( + "Generate Commit Message", + &git::GenerateCommitMessage, + &editor_focus_handle, + cx, + ) + } + }) + }; + + Some(button.into_any_element()) } pub(crate) fn render_co_authors(&self, cx: &Context) -> Option { @@ -4859,23 +5352,17 @@ impl GitPanel { &self, id: impl Into, keybinding_target: Option, + disabled: bool, cx: &mut Context, ) -> impl IntoElement { + let menu_open = self.commit_menu_handle.is_deployed(); + PopoverMenu::new(id.into()) .trigger( - ui::ButtonLike::new_rounded_right("commit-split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ButtonSize::None) - .child( - h_flex() - .px_1() - .h_full() - .justify_center() - .border_l_1() - .border_color(cx.theme().colors().border) - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), + crate::render_split_button_chevron_trigger("commit-split-button-right", menu_open) + .disabled(disabled), ) + .with_handle(self.commit_menu_handle.clone()) .menu({ let git_panel = cx.entity(); let has_previous_commit = self.head_commit(cx).is_some(); @@ -4917,10 +5404,20 @@ impl GitPanel { } }) .anchor(Anchor::TopRight) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) + } + + pub fn is_generating_commit_message(&self) -> bool { + self.generate_commit_message_task.is_some() } pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) { - if self.has_unstaged_conflicts() { + if self.generate_commit_message_task.is_some() { + (false, "Generating commit message...") + } else if self.has_unstaged_conflicts() { (false, "You must resolve conflicts before committing") } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending { (false, "No changes to commit") @@ -5002,19 +5499,16 @@ impl GitPanel { let has_unstaged_changes = self.has_unstaged_changes(); let has_new_changes = self.new_count > 0; let has_stash_items = self.stash_entries.entries.len() > 0; + let focus_handle = self.focus_handle.clone(); + let menu_open = self.changes_actions_menu_handle.is_deployed(); PopoverMenu::new(id.into()) - .trigger( - ui::ButtonLike::new_rounded_right("git-changes-actions-split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ButtonSize::None) - .child( - div() - .px_1() - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), - ) + .trigger(crate::render_split_button_chevron_trigger( + "changes-actions-split-button-right", + menu_open, + )) + .with_handle(self.changes_actions_menu_handle.clone()) .menu(move |window, cx| { Some(git_panel_context_menu( has_tracked_changes, @@ -5028,15 +5522,18 @@ impl GitPanel { )) }) .anchor(Anchor::TopRight) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) } fn render_git_changes_actions_button(&self, cx: &mut Context) -> impl IntoElement { - let (text, action, stage, tooltip) = - if self.total_staged_count() == self.entry_count && self.entry_count > 0 { - ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") - } else { - ("Stage All", StageAll.boxed_clone(), true, "git add --all") - }; + let (text, action, stage, tooltip) = if self.primary_changes_action_stages() { + ("Stage All", StageAll.boxed_clone(), true, "git add --all") + } else { + ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") + }; SplitButton::new( ButtonLike::new_rounded_left("git-changes-actions-split-button-left") @@ -5069,7 +5566,7 @@ impl GitPanel { _window: &mut Window, cx: &mut Context, ) -> Option { - if matches!(self.git_access, GitAccess::No) { + if matches!(self.git_access, Some(GitAccess::No)) { return None; } @@ -5152,6 +5649,7 @@ impl GitPanel { focus_handle, true, self.pending_remote_operation, + self.remote_action_menu_handle.clone(), )) }) .into_any_element(), @@ -5172,14 +5670,6 @@ impl GitPanel { let branch = active_repository.read(cx).branch.clone(); let head_commit = active_repository.read(cx).head_commit.clone(); - let footer_size = px(32.); - let gap = px(9.0); - let max_height = panel_editor_style - .text - .line_height_in_pixels(window.rem_size()) - * MAX_PANEL_EDITOR_LINES - + gap; - let git_panel = cx.entity(); let display_name = SharedString::from(Arc::from( active_repository @@ -5203,6 +5693,58 @@ impl GitPanel { false }; + let vertical_buttons = v_flex() + .h_full() + .gap_px() + .p_1p5() + .opacity(0.6) + .hover(|s| s.opacity(1.0)) + .child( + IconButton::new("expand-commit-editor", IconName::MaximizeAlt) + .icon_size(IconSize::Small) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Open Commit Modal", + &git::ExpandCommitEditor, + &editor_focus_handle, + cx, + ) + } + }) + .on_click(cx.listener({ + move |_, _, window, cx| { + window.dispatch_action(git::ExpandCommitEditor.boxed_clone(), cx) + } + })), + ) + .child({ + let (icon, label) = if self.commit_editor_expanded { + (IconName::Minimize, "Collapse Commit Editor") + } else { + (IconName::Maximize, "Expand Commit Editor") + }; + let focus_handle = self.focus_handle.clone(); + + IconButton::new("fill-commit-editor", icon) + .icon_size(IconSize::Small) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + label, + &git::ToggleFillCommitEditor, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener({ + move |_, _, window, cx| { + window.dispatch_action(git::ToggleFillCommitEditor.boxed_clone(), cx) + } + })) + }); + let footer = v_flex() .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0()) .child(PanelRepoFooter::new( @@ -5236,13 +5778,8 @@ impl GitPanel { .child( panel_editor_container(window, cx) .id("commit-editor-container") - .cursor_text() - .relative() .w_full() .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0()) - .when(!self.commit_editor_expanded, |this| { - this.h(max_height + footer_size) - }) .border_t_1() .border_color(if title_exceeds_limit { cx.theme().status().warning_border @@ -5252,20 +5789,38 @@ impl GitPanel { .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { window.focus(&this.commit_editor.focus_handle(cx), cx); })) + .child( + h_flex() + .size_full() + .child( + div() + .pt_2() + .px_2() + .h_full() + .flex_grow_1() + .cursor_text() + .on_action(|&zed_actions::editor::MoveUp, _, cx| { + cx.stop_propagation(); + }) + .on_action(|&zed_actions::editor::MoveDown, _, cx| { + cx.stop_propagation(); + }) + .child(EditorElement::new( + &self.commit_editor, + panel_editor_style, + )), + ) + .child(vertical_buttons), + ) .child( h_flex() .id("commit-footer") + .w_full() + .p_1p5() .border_t_1() .when(editor_is_long, |el| { el.border_color(cx.theme().colors().border_variant) }) - .absolute() - .bottom_0() - .left_0() - .w_full() - .px_2() - .h(footer_size) - .flex_none() .justify_between() .child( self.render_generate_commit_message_button(cx) @@ -5277,80 +5832,6 @@ impl GitPanel { .children(enable_coauthors) .child(self.render_commit_button(cx)), ), - ) - .child( - div() - .when(self.commit_editor_expanded, |this| { - this.flex_1().min_h_0().pb(footer_size) - }) - .pr_2p5() - .on_action(|&zed_actions::editor::MoveUp, _, cx| { - cx.stop_propagation(); - }) - .on_action(|&zed_actions::editor::MoveDown, _, cx| { - cx.stop_propagation(); - }) - .child(EditorElement::new(&self.commit_editor, panel_editor_style)), - ) - .child( - v_flex() - .absolute() - .top_2() - .right_2() - .gap_px() - .opacity(0.6) - .hover(|s| s.opacity(1.0)) - .child( - IconButton::new("expand-commit-editor", IconName::MaximizeAlt) - .icon_size(IconSize::Small) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - "Open Commit Modal", - &git::ExpandCommitEditor, - &editor_focus_handle, - cx, - ) - } - }) - .on_click(cx.listener({ - move |_, _, window, cx| { - window.dispatch_action( - git::ExpandCommitEditor.boxed_clone(), - cx, - ) - } - })), - ) - .child({ - let (icon, label) = if self.commit_editor_expanded { - (IconName::Minimize, "Collapse Commit Editor") - } else { - (IconName::Maximize, "Expand Commit Editor") - }; - let focus_handle = self.focus_handle.clone(); - - IconButton::new("fill-commit-editor", icon) - .icon_size(IconSize::Small) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - label, - &git::ToggleFillCommitEditor, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener({ - move |_, _, window, cx| { - window.dispatch_action( - git::ToggleFillCommitEditor.boxed_clone(), - cx, - ) - } - })) - }), ), ); @@ -5370,7 +5851,7 @@ impl GitPanel { Color::Default }; - div() + h_flex() .id("commit-wrapper") .on_hover(cx.listener(move |this, hovered, _, cx| { this.show_placeholders = @@ -5378,60 +5859,59 @@ impl GitPanel { cx.notify() })) .child(SplitButton::new( - ButtonLike::new_rounded_left(ElementId::Name( - format!("split-button-left-{}", title).into(), - )) - .layer(ElevationIndex::ModalSurface) - .size(ButtonSize::Compact) - .child( - Label::new(title) - .size(LabelSize::Small) - .color(label_color) - .mr_0p5(), - ) - .on_click({ - let git_panel = cx.weak_entity(); - move |_, window, cx| { - telemetry::event!("Git Committed", source = "Git Panel"); - git_panel - .update(cx, |git_panel, cx| { - git_panel.commit_changes( - CommitOptions { - amend, - signoff, - allow_empty: false, - }, - window, + ButtonLike::new_rounded_left(format!("split-button-left-{}", title)) + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) + .disabled(!can_commit || self.modal_open) + .child( + Label::new(title) + .size(LabelSize::Small) + .color(label_color) + .mr_0p5(), + ) + .on_click({ + let git_panel = cx.weak_entity(); + move |_, window, cx| { + telemetry::event!("Git Committed", source = "Git Panel"); + git_panel + .update(cx, |git_panel, cx| { + git_panel.commit_changes( + CommitOptions { + amend, + signoff, + allow_empty: false, + }, + window, + cx, + ); + }) + .ok(); + } + }) + .tooltip({ + let handle = commit_tooltip_focus_handle.clone(); + move |_window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + format!( + "git commit{}{}", + if amend { " --amend" } else { "" }, + if signoff { " --signoff" } else { "" } + ), + &handle.clone(), cx, - ); - }) - .ok(); - } - }) - .disabled(!can_commit || self.modal_open) - .tooltip({ - let handle = commit_tooltip_focus_handle.clone(); - move |_window, cx| { - if can_commit { - Tooltip::with_meta_in( - tooltip, - Some(&git::Commit), - format!( - "git commit{}{}", - if amend { " --amend" } else { "" }, - if signoff { " --signoff" } else { "" } - ), - &handle.clone(), - cx, - ) - } else { - Tooltip::simple(tooltip, cx) + ) + } else { + Tooltip::simple(tooltip, cx) + } } - } - }), + }), self.render_git_commit_menu( ElementId::Name(format!("split-button-right-{}", title).into()), Some(commit_tooltip_focus_handle), + self.generate_commit_message_task.is_some(), cx, ) .into_any_element(), @@ -5626,7 +6106,11 @@ impl GitPanel { GitPanelTab::Changes, ActivateChangesTab.boxed_clone(), )) - .child(Divider::vertical().color(ui::DividerColor::BorderFaded)) + .child( + Divider::vertical() + .color(ui::DividerColor::BorderFaded) + .h_full(), + ) .child(tab( ElementId::Name("history-tab".into()), active_tab != GitPanelTab::Changes, @@ -5640,41 +6124,43 @@ impl GitPanel { fn render_history_tab(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex().flex_1().size_full().overflow_hidden().map(|this| { let has_repo = self.active_repository.is_some(); - let has_commits = self - .commit_history_shas - .as_ref() - .map_or(false, |shas| !shas.is_empty()); - let is_loading = self.commit_history_shas.is_none() && has_repo; - if is_loading { - this.child( - h_flex() - .flex_1() - .justify_center() - .child(Label::new("Loading Commit History…").color(Color::Muted)), - ) - } else if !has_repo || !has_commits { - this.child( - h_flex() - .flex_1() - .justify_center() - .child(Label::new("No commits yet").color(Color::Muted)), - ) - } else { - match self.render_commit_history(window, cx) { - Some(history) => this.child(history), - None => this.child( - h_flex() - .flex_1() - .justify_center() - .child(Label::new("Failed to load commits").color(Color::Muted)), - ), + match &self.commit_history { + _ if !has_repo => { + this.child(Self::render_history_placeholder("No repository found")) + } + CommitHistory::Error(_) => this.child(Self::render_history_placeholder( + "Failed to load commit history", + )), + CommitHistory::Loading => { + this.child(Self::render_history_placeholder("Loading Commit History…")) + } + CommitHistory::Loaded(entries) if entries.is_empty() => { + this.child(Self::render_history_placeholder("No commits yet")) } + CommitHistory::Loaded(_) => match self.render_commit_history(window, cx) { + Some(history) => this.child(history), + None => this.child(Self::render_history_placeholder("Failed to load commits")), + }, } }) } + fn render_history_placeholder(message: &'static str) -> impl IntoElement { + h_flex() + .flex_1() + .justify_center() + .child(Label::new(message).color(Color::Muted)) + } + + fn commit_history_entries(&self) -> &[CommitHistoryEntry] { + match &self.commit_history { + CommitHistory::Loaded(entries) => entries, + CommitHistory::Loading | CommitHistory::Error(_) => &[], + } + } + fn select_next_history_entry(&mut self, cx: &mut Context) { - let count = self.commit_history_shas.as_ref().map_or(0, Vec::len); + let count = self.commit_history_entries().len(); if count == 0 { return; } @@ -5690,7 +6176,7 @@ impl GitPanel { } fn select_previous_history_entry(&mut self, cx: &mut Context) { - let count = self.commit_history_shas.as_ref().map_or(0, Vec::len); + let count = self.commit_history_entries().len(); if count == 0 { return; } @@ -5709,14 +6195,14 @@ impl GitPanel { let Some(index) = self.focused_history_entry else { return; }; - let Some(sha) = self.commit_history_shas.as_ref().and_then(|s| s.get(index)) else { + let Some(entry) = self.commit_history_entries().get(index) else { return; }; let Some(active_repository) = self.active_repository.as_ref() else { return; }; CommitView::open( - sha.to_string(), + entry.sha.to_string(), active_repository.downgrade(), self.workspace.clone(), None, @@ -5726,6 +6212,37 @@ impl GitPanel { ); } + fn deploy_history_context_menu( + &mut self, + position: Point, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(commit) = self.commit_history_entries().get(index).cloned() else { + return; + }; + let Some(repository) = self.active_repository.as_ref() else { + return; + }; + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.sha, + tag_names: commit.tag_names, + }, + CommitContextMenuSource::GitPanel, + None, + self.focus_handle.clone(), + Some(repository.downgrade()), + self.workspace.clone(), + window, + cx, + ); + self.focused_history_entry = Some(index); + self.history_keyboard_nav = false; + self.set_context_menu(context_menu, position, window, cx); + } + fn activate_changes_tab( &mut self, _: &ActivateChangesTab, @@ -5753,12 +6270,10 @@ impl GitPanel { GitPanelTab::History => { self.focus_handle.focus(window, cx); self.load_commit_history(cx); - self.focused_history_entry = Some(0); } GitPanelTab::Changes => { self.focus_handle.focus(window, cx); - self.commit_history_shas.take(); - self.focused_history_entry = None; + self.set_commit_history(CommitHistory::Loading, cx); self._repo_subscriptions.clear(); } } @@ -5770,12 +6285,9 @@ impl GitPanel { return; }; - let Some(branch) = active_repository.read(cx).branch.as_ref() else { + let Some(log_source) = Self::commit_history_log_source(active_repository, cx) else { return; }; - - let branch_name = branch.name().to_string(); - let log_source = LogSource::Branch(branch_name.into()); let log_order = LogOrder::DateOrder; // Kick off the git log fetch so data is ready when the user switches to History. @@ -5786,47 +6298,78 @@ impl GitPanel { } fn load_commit_history(&mut self, cx: &mut Context) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; if self._repo_subscriptions.is_empty() { self._repo_subscriptions.push(cx.subscribe( - active_repository, + &active_repository, |this, _repo, event, cx| { if let RepositoryEvent::GraphEvent(_, _) = event { if this.active_tab == GitPanelTab::History { - this.fetch_commit_history_shas(cx); + this.fetch_commit_history_entries(cx); } } }, )); self._repo_subscriptions - .push(cx.observe(active_repository, |_this, _repo, cx| { + .push(cx.observe(&active_repository, |_this, _repo, cx| { cx.notify(); })); } - self.fetch_commit_history_shas(cx); + self.fetch_commit_history_entries(cx); } - fn fetch_commit_history_shas(&mut self, cx: &mut Context) { - let Some(active_repository) = self.active_repository.as_ref() else { + fn fetch_commit_history_entries(&mut self, cx: &mut Context) { + let Some(active_repository) = self.active_repository.clone() else { return; }; - let Some(branch) = active_repository.read(cx).branch.as_ref() else { + let Some(log_source) = Self::commit_history_log_source(&active_repository, cx) else { + // No HEAD commit at all (unborn/empty repository). + self.set_commit_history(CommitHistory::Loaded(Rc::from([])), cx); return; }; - - let branch_name = branch.name().to_string(); - let log_source = LogSource::Branch(branch_name.into()); let log_order = LogOrder::DateOrder; - self.commit_history_shas = Some(active_repository.update(cx, |repository, cx| { + let (entries, is_loading, error) = active_repository.update(cx, |repository, cx| { let response = repository.graph_data(log_source, log_order, 0..usize::MAX, cx); - response.commits.iter().map(|commit| commit.sha).collect() - })); + let entries: Rc<[CommitHistoryEntry]> = response + .commits + .iter() + .map(CommitHistoryEntry::from) + .collect(); + (entries, response.is_loading, response.error) + }); + + self.set_commit_history(commit_history_from_response(entries, is_loading, error), cx); + } + + fn set_commit_history(&mut self, commit_history: CommitHistory, cx: &mut Context) { + let changed = self.commit_history != commit_history; + self.commit_history = commit_history; + // Keep the focused entry within range as the history grows or clears. + let count = self.commit_history_entries().len(); + let focused = self.focused_history_entry.unwrap_or(0); + self.focused_history_entry = (count > 0).then(|| focused.min(count - 1)); + if changed { + cx.notify(); + } + } + + fn commit_history_log_source( + active_repository: &Entity, + cx: &App, + ) -> Option { + let repository = active_repository.read(cx); + let head_commit = repository.head_commit.as_ref()?; + if let Some(branch) = repository.branch.as_ref() { + Some(LogSource::Branch(branch.name().to_string().into())) + } else { + Some(LogSource::Sha(head_commit.sha.as_ref().parse().ok()?)) + } } fn git_remote(&self, cx: &mut App) -> Option { @@ -5846,17 +6389,21 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> Option { - let shas = self.commit_history_shas.clone()?; + let CommitHistory::Loaded(entries) = &self.commit_history else { + return None; + }; + let entries = entries.clone(); let active_repository = self.active_repository.as_ref()?; let workspace = self.workspace.clone(); let repo_weak = active_repository.downgrade(); - let item_count = shas.len(); + let item_count = entries.len(); let commit_history_scroll_handle = self.commit_history_scroll_handle.clone(); let remote = self.git_remote(cx); let focused_history_entry = self.focused_history_entry; let is_panel_focused = self.focus_handle.is_focused(window); let show_focus_border = self.history_keyboard_nav; + let has_context_menu = self.context_menu.is_some(); let ahead_count = active_repository .read(cx) @@ -5884,10 +6431,11 @@ impl GitPanel { let visible_data: Vec>> = repo_weak .update(cx, |repository, cx| { - shas[range.clone()] + entries[range.clone()] .iter() - .map(|sha| { - match repository.fetch_commit_data(*sha, false, cx) { + .map(|entry| { + match repository.fetch_commit_data(entry.sha, false, cx) + { CommitDataState::Loaded(data) => Some(data.clone()), CommitDataState::Loading(_) => None, } @@ -5896,16 +6444,17 @@ impl GitPanel { }) .unwrap_or_default(); - shas[range.clone()] + entries[range.clone()] .iter() .zip(visible_data) .enumerate() - .map(|(ix, (sha, data))| { + .map(|(ix, (entry, data))| { let index = range.start + ix; - let sha_string = sha.to_string(); + let sha_string = entry.sha.to_string(); let sha_shared: SharedString = sha_string.clone().into(); let short_sha: SharedString = sha_string[..7.min(sha_string.len())].to_string().into(); + let tag_names = entry.tag_names.clone(); let (subject, author_name, author_email, timestamp): ( SharedString, @@ -5980,7 +6529,51 @@ impl GitPanel { h_flex() .gap_1() .w_full() + .min_w_0() .child(Label::new(subject).truncate()) + .children((!tag_names.is_empty()).then(|| { + let hidden_tag_count = tag_names + .len() + .saturating_sub(MAX_HISTORY_TAG_CHIPS); + h_flex() + .gap_1() + .min_w_0() + .children( + tag_names + .iter() + .take(MAX_HISTORY_TAG_CHIPS) + .map(|tag_name| { + let tag_name = tag_name.clone(); + Chip::new(tag_name.clone()) + .truncate() + .when( + !has_context_menu, + |chip| { + chip.tooltip( + Tooltip::text( + tag_name, + ), + ) + }, + ) + }), + ) + .when(hidden_tag_count > 0, |this| { + let hidden_tag_names = tag_names + [MAX_HISTORY_TAG_CHIPS..] + .join(", "); + this.child( + Chip::new(format!( + "+{hidden_tag_count}" + )) + .when(!has_context_menu, |chip| { + chip.tooltip(Tooltip::text( + hidden_tag_names, + )) + }), + ) + }) + })) .when(is_unpushed, |this| { this.child( Icon::new(IconName::ArrowUp) @@ -6014,13 +6607,15 @@ impl GitPanel { .color(Color::Muted), ), ) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "View Commit", - None, - short_sha.clone(), - cx, - ) + .when(!has_context_menu, |this| { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "View Commit", + None, + short_sha.clone(), + cx, + ) + }) }) .on_mouse_down(gpui::MouseButton::Left, { let git_panel = git_panel.clone(); @@ -6034,12 +6629,28 @@ impl GitPanel { .ok(); } }) - .on_click(move |_, window, cx| { - CommitView::open( - sha_for_click.clone(), - repo.clone(), - workspace.clone(), - None, + .on_mouse_down(MouseButton::Right, { + let git_panel = git_panel.clone(); + move |event, window, cx| { + git_panel + .update(cx, |panel, cx| { + panel.deploy_history_context_menu( + event.position, + index, + window, + cx, + ); + }) + .ok(); + cx.stop_propagation(); + } + }) + .on_click(move |_, window, cx| { + CommitView::open( + sha_for_click.clone(), + repo.clone(), + workspace.clone(), + None, None, window, cx, @@ -6059,7 +6670,7 @@ impl GitPanel { fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { let content = match (self.git_access, &self.active_repository) { - (GitAccess::No, Some(repository)) => self.render_unsafe_repo_ui(repository, cx), + (Some(GitAccess::No), Some(repository)) => self.render_unsafe_repo_ui(repository, cx), (_, None) => self.render_uninitialized_ui(cx), (_, Some(_)) => self.render_no_changes_ui(cx), }; @@ -6086,7 +6697,7 @@ impl GitPanel { .style(ButtonStyle::Outlined) .on_click(move |_, _, cx| { cx.defer(move |cx| { - cx.dispatch_action(&BranchDiff); + cx.dispatch_action(&DeployBranchDiff); }) }), ) @@ -6236,7 +6847,7 @@ impl GitPanel { move |_, window, cx| { git_panel .update(cx, |this, cx| { - this.toggle_staged_for_entry(&entry, window, cx); + this.toggle_staged_for_entry(&entry, StageIntent::Toggle, window, cx); cx.stop_propagation(); }) .ok(); @@ -6336,6 +6947,9 @@ impl GitPanel { cx, )); } + Some(GitListEntry::EmptySection(section)) => { + items.push(this.render_empty_section(*section)); + } None => {} } } @@ -6344,43 +6958,15 @@ impl GitPanel { }), ) .when(is_tree_view, |list| { - let indent_size = px(TREE_INDENT); list.with_decoration( - ui::indent_guides(indent_size, IndentGuideColors::panel(cx)) + ui::indent_guides(px(TREE_INDENT), IndentGuideColors::panel(cx)) + .with_left_offset(INDENT_GUIDE_LEFT_OFFSET) .with_compute_indents_fn( cx.entity(), |this, range, _window, _cx| { this.compute_visible_depths(range) }, - ) - .with_render_fn(cx.entity(), |_, params, _, _| { - // Magic number to align the tree item is 3 here - // because we're using 12px as the left-side padding - // and 3 makes the alignment work with the bounding box of the icon - let left_offset = px(TREE_INDENT + 3_f32); - let indent_size = params.indent_size; - let item_height = params.item_height; - - params - .indent_guides - .into_iter() - .map(|layout| { - let bounds = Bounds::new( - point( - layout.offset.x * indent_size + left_offset, - layout.offset.y * item_height, - ), - size(px(1.), layout.length * item_height), - ); - RenderedIndentGuide { - bounds, - layout, - is_active: false, - hitbox: None, - } - }) - .collect() - }), + ), ) }) .group("entries") @@ -6409,7 +6995,7 @@ impl GitPanel { } fn entry_label(&self, label: impl Into, color: Color) -> Label { - Label::new(label.into()).color(color) + Label::new(label.into()).single_line().color(color) } fn list_item_height(&self) -> Rems { @@ -6427,21 +7013,33 @@ impl GitPanel { let id: ElementId = ElementId::Name(format!("header_{}", ix).into()); let checkbox_id: ElementId = ElementId::Name(format!("header_{}_checkbox", ix).into()); let group_name: SharedString = format!("header_{}", ix).into(); - let toggle_state = self.header_state(header.header); let section = header.header; let weak = cx.weak_entity(); + let stage_intent = StageIntent::for_section(section); + let toggle_state = stage_intent.checkbox_state(|| self.header_state(header.header)); + + let all_conflicts_resolved = section == Section::Conflict + && self.conflicted_count > 0 + && self.conflicted_staged_count == self.conflicted_count; + + let section_is_empty = !self + .entries + .get(ix + 1) + .is_some_and(GitListEntry::is_selectable); h_flex() .id(id) - .cursor_pointer() .group(group_name) .h(self.list_item_height()) .w_full() - .pl_3() + .pl_2p5() .pr_1() .gap_2() .justify_between() - .hover(|s| s.bg(cx.theme().colors().ghost_element_hover)) + .when(!section_is_empty && !all_conflicts_resolved, |this| { + this.cursor_pointer() + .hover(|s| s.bg(cx.theme().colors().ghost_element_hover)) + }) .border_1() .border_r_2() .child( @@ -6449,20 +7047,39 @@ impl GitPanel { .color(Color::Muted) .size(LabelSize::Small), ) - .child( - Checkbox::new(checkbox_id, toggle_state) - .disabled(!has_write_access) + .child(if section_is_empty { + gpui::Empty.into_any_element() + } else { + let checkbox = Checkbox::new(checkbox_id, toggle_state) + .disabled(!has_write_access || all_conflicts_resolved) .fill() - .elevation(ElevationIndex::Surface), - ) + .elevation(ElevationIndex::Surface); + let tooltip_label = if all_conflicts_resolved { + Some("All conflicts marked as resolved") + } else { + match stage_intent { + StageIntent::Stage => Some("Stage All"), + StageIntent::Unstage => Some("Unstage All"), + StageIntent::Toggle => None, + } + }; + if let Some(label) = tooltip_label { + checkbox + .tooltip(move |_window, cx| Tooltip::simple(label, cx)) + .into_any_element() + } else { + checkbox.into_any_element() + } + }) .on_click(move |_, window, cx| { - if !has_write_access { + if !has_write_access || section_is_empty || all_conflicts_resolved { return; } weak.update(cx, |this, cx| { this.toggle_staged_for_entry( &GitListEntry::Header(GitHeaderEntry { header: section }), + stage_intent, window, cx, ); @@ -6473,6 +7090,26 @@ impl GitPanel { .into_any_element() } + fn render_empty_section(&self, section: Section) -> AnyElement { + let message = match section { + Section::Staged => "No staged changes yet", + Section::Unstaged => "No unstaged changes", + _ => "No changes", + }; + h_flex() + .h(self.list_item_height()) + .w_full() + .pl_2p5() + .pr_1() + .opacity(0.8) + .child( + Label::new(message) + .color(Color::Placeholder) + .size(LabelSize::Small), + ) + .into_any_element() + } + pub fn load_commit_details( &self, sha: String, @@ -6494,13 +7131,20 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { + let stage_intent = self.stage_intent_for_entry_index(ix); let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else { return; }; - let stage_title = if entry.status.staging().is_fully_staged() { - "Unstage File" - } else { + // Resolve against the pending-op-aware status (like the checkboxes do) + // so the menu label can't lag behind a just-clicked checkbox. + let repo = self.active_repository.as_ref().map(|repo| repo.read(cx)); + let stage_title = if stage_intent.resolve_with(|| match repo { + Some(repo) => GitPanel::stage_status_for_entry(entry, repo), + None => entry.status.staging(), + }) { "Stage File" + } else { + "Unstage File" }; let restore_title = if entry.status.is_created() { "Trash File" @@ -6514,6 +7158,9 @@ impl GitPanel { .action(stage_title, ToggleStaged.boxed_clone()) .action(restore_title, git::RestoreFile::default().boxed_clone()) .separator() + .action("Unstaged Changes", ViewUnstagedChanges.boxed_clone()) + .action("Staged Changes", ViewStagedChanges.boxed_clone()) + .separator() .action_disabled_when( !is_created, "Add to .gitignore", @@ -6526,7 +7173,7 @@ impl GitPanel { ) .separator() .action("Open Diff", menu::Confirm.boxed_clone()) - .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone()) + .action("Open File Diff", menu::SecondaryConfirm.boxed_clone()) .action("View File", ViewFile.boxed_clone()) .when(!is_created, |context_menu| { context_menu @@ -6567,9 +7214,11 @@ impl GitPanel { &mut self, context_menu: Entity, position: Point, - window: &Window, + window: &mut Window, cx: &mut Context, ) { + window.focus(&context_menu.focus_handle(cx), cx); + let subscription = cx.subscribe_in( &context_menu, window, @@ -6648,14 +7297,19 @@ impl GitPanel { ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into()); let stage_status = GitPanel::stage_status_for_entry(entry, &repo); - let mut is_staged: ToggleState = match stage_status { - StageStatus::Staged => ToggleState::Selected, - StageStatus::Unstaged => ToggleState::Unselected, - StageStatus::PartiallyStaged => ToggleState::Indeterminate, - }; - if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { - is_staged = ToggleState::Selected; - } + let stage_intent = self.stage_intent_for_entry_index(ix); + let resolved_conflict = self.is_resolved_conflict(ix, cx); + let toggle_state = stage_intent.checkbox_state(|| { + if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { + ToggleState::Selected + } else { + match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + } + } + }); let handle = cx.weak_entity(); @@ -6732,7 +7386,7 @@ impl GitPanel { .id(id) .h(self.list_item_height()) .w_full() - .pl_3() + .pl_2p5() .pr_1() .gap_1p5() .border_1() @@ -6761,20 +7415,24 @@ impl GitPanel { .occlude() .cursor_pointer() .child( - Checkbox::new(checkbox_id, is_staged) - .disabled(!has_write_access) + Checkbox::new(checkbox_id, toggle_state) .fill() .elevation(ElevationIndex::Surface) + .disabled(!has_write_access || resolved_conflict) .on_click_ext({ let entry = entry.clone(); let this = cx.weak_entity(); move |_, click, window, cx| { this.update(cx, |this, cx| { - if !has_write_access { + if !has_write_access || resolved_conflict { return; } if click.modifiers().shift { - this.stage_bulk(ix, cx); + this.stage_bulk( + ix, + stage_intent != StageIntent::Unstage, + cx, + ); } else { let list_entry = if GitPanelSettings::get_global(cx).tree_view { @@ -6785,7 +7443,12 @@ impl GitPanel { } else { GitListEntry::Status(entry.clone()) }; - this.toggle_staged_for_entry(&list_entry, window, cx); + this.toggle_staged_for_entry( + &list_entry, + stage_intent, + window, + cx, + ); } cx.stop_propagation(); }) @@ -6793,13 +7456,12 @@ impl GitPanel { } }) .tooltip(move |_window, cx| { - let action = match stage_status { - StageStatus::Staged => "Unstage", - StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", - }; - let tooltip_name = action.to_string(); - - Tooltip::for_action(tooltip_name, &ToggleStaged, cx) + if resolved_conflict { + Tooltip::simple("Conflict marked as resolved", cx) + } else { + let action = stage_intent.label(|| stage_status); + Tooltip::for_action(action, &ToggleStaged, cx) + } }), ), ) @@ -6897,11 +7559,13 @@ impl GitPanel { StageStatus::PartiallyStaged }; - let toggle_state: ToggleState = match stage_status { + let stage_intent = StageIntent::for_section(entry.key.section); + let resolved_conflict = self.is_resolved_conflict(ix, cx); + let toggle_state = stage_intent.checkbox_state(|| match stage_status { StageStatus::Staged => ToggleState::Selected, StageStatus::Unstaged => ToggleState::Unselected, StageStatus::PartiallyStaged => ToggleState::Indeterminate, - }; + }); let name_row = h_flex() .min_w_0() @@ -6927,7 +7591,7 @@ impl GitPanel { .h(self.list_item_height()) .min_w_0() .w_full() - .pl_3() + .pl_2p5() .pr_1() .gap_1p5() .justify_between() @@ -6948,7 +7612,7 @@ impl GitPanel { .cursor_pointer() .child( Checkbox::new(checkbox_id, toggle_state) - .disabled(!has_write_access) + .disabled(!has_write_access || resolved_conflict) .fill() .elevation(ElevationIndex::Surface) .on_click({ @@ -6956,11 +7620,12 @@ impl GitPanel { let this = cx.weak_entity(); move |_, window, cx| { this.update(cx, |this, cx| { - if !has_write_access { + if !has_write_access || resolved_conflict { return; } this.toggle_staged_for_entry( &GitListEntry::Directory(entry.clone()), + stage_intent, window, cx, ); @@ -6970,11 +7635,12 @@ impl GitPanel { } }) .tooltip(move |_window, cx| { - let action = match stage_status { - StageStatus::Staged => "Unstage", - StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", - }; - Tooltip::simple(format!("{action} folder"), cx) + if resolved_conflict { + Tooltip::simple("Conflicts marked as resolved", cx) + } else { + let action = stage_intent.label(|| stage_status); + Tooltip::simple(format!("{action} Folder"), cx) + } }), ), ) @@ -7122,14 +7788,17 @@ impl GitPanel { }) } - fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) { + fn stage_bulk(&mut self, mut index: usize, stage: bool, cx: &mut Context<'_, Self>) { let Some(op) = self.bulk_staging.as_ref() else { return; }; let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else { return; }; - if let Some(entry) = self.entries.get(index) + // Only a staged anchor survives the next entries refresh, so there's no + // point re-anchoring on the entry we're about to unstage. + if stage + && let Some(entry) = self.entries.get(index) && let Some(entry) = entry.status_entry() { self.set_bulk_staging_anchor(entry.repo_path.clone(), cx); @@ -7137,14 +7806,21 @@ impl GitPanel { if index < anchor_index { std::mem::swap(&mut index, &mut anchor_index); } + let Some(repo) = self.active_repository.clone() else { + return; + }; + let repo = repo.read(cx); + // Conflicts only change staging via their own explicit controls; a + // range sweep must neither mark them resolved nor un-resolve them. let entries = self .entries .get(anchor_index..=index) .unwrap_or_default() .iter() .filter_map(|entry| entry.status_entry().cloned()) + .filter(|entry| !repo.had_conflict_on_last_merge_head_change(&entry.repo_path)) .collect::>(); - self.change_file_stage(true, entries, cx); + self.change_file_stage(stage, entries, cx); } fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) { @@ -7165,6 +7841,53 @@ impl GitPanel { } } +struct GenerateCommitMessageConfigurationTooltip; + +impl Render for GenerateCommitMessageConfigurationTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + ui::tooltip_container(cx, |container, _cx| { + container + .gap_1p5() + .child(Label::new( + "Configure an LLM provider to generate commit messages.", + )) + .child( + h_flex() + .gap_1() + .child( + Button::new("configure-commit-message-provider", "Configure Provider") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .label_size(LabelSize::Small) + .on_click(|_, window, cx| { + window.dispatch_action( + zed_actions::OpenSettingsAt { + path: "llm_providers".to_string(), + target: None, + } + .boxed_clone(), + cx, + ); + }), + ) + .child( + Button::new("llm-provider-docs", "See Docs") + .style(ButtonStyle::OutlinedGhost) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::Small), + ) + .label_size(LabelSize::Small) + .on_click(move |_, _, cx| { + cx.open_url(&zed_urls::llm_provider_docs(cx)) + }), + ), + ) + }) + } +} + impl GitPanel { pub fn selected_file_history_target(&self) -> Option<(Entity, RepoPath)> { let entry = self.get_selected_entry()?.status_entry()?; @@ -7251,6 +7974,8 @@ impl Render for GitPanel { .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_solo_diff)) .on_action(cx.listener(Self::view_file)) + .on_action(cx.listener(Self::view_unstaged_changes)) + .on_action(cx.listener(Self::view_staged_changes)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) @@ -7261,6 +7986,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::set_sort_by_name)) .on_action(cx.listener(Self::set_group_by_none)) .on_action(cx.listener(Self::set_group_by_status)) + .on_action(cx.listener(Self::set_group_by_staging)) .on_action(cx.listener(Self::toggle_tree_view)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) @@ -7428,8 +8154,6 @@ impl PanelHeader for GitPanel {} pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div { v_flex() .size_full() - .gap(px(8.)) - .p_2() .bg(cx.theme().colors().editor_background) } @@ -7491,6 +8215,7 @@ impl GitPanelMessageTooltip { remote_url.as_deref(), provider_registry, )), + tag_names: Vec::new(), }; this.update(cx, |this: &mut GitPanelMessageTooltip, cx| { @@ -7649,9 +8374,9 @@ impl RenderOnce for PanelRepoFooter { }); h_flex() - .h_9() .w_full() .px_2() + .py_1p5() .justify_between() .gap_1() .child( @@ -8028,20 +8753,23 @@ pub(crate) fn commit_title_exceeds_limit(title: &str, max_length: usize) -> bool #[cfg(test)] mod tests { + use editor::SplittableEditor; use git::{ repository::repo_path, - status::{StatusCode, UnmergedStatus, UnmergedStatusCode}, + status::{StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}, }; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, px}; use indoc::indoc; use project::FakeFs; + use search::{BufferSearchBar, buffer_search::Deploy}; use serde_json::json; use settings::SettingsStore; + use std::any::TypeId; use theme::LoadThemes; use util::path; use util::rel_path::rel_path; - use workspace::MultiWorkspace; + use workspace::{MultiWorkspace, ToolbarItemEvent, ToolbarItemLocation}; use super::*; @@ -8058,6 +8786,61 @@ mod tests { }); } + #[test] + fn test_tree_view_directory_expansion_is_scoped_to_section() { + let entry = |path, status| GitStatusEntry { + repo_path: repo_path(path), + status, + staging: StageStatus::Unstaged, + diff_stat: None, + }; + let mut state = TreeViewState::default(); + let mut seen_directories = HashSet::default(); + + state.build_tree_entries( + Section::Tracked, + vec![entry("src/tracked.rs", StatusCode::Modified.worktree())], + &mut seen_directories, + ); + state.build_tree_entries( + Section::New, + vec![entry("src/new.rs", FileStatus::Untracked)], + &mut seen_directories, + ); + + let tracked_key = TreeKey { + section: Section::Tracked, + path: repo_path("src"), + }; + let new_key = TreeKey { + section: Section::New, + path: repo_path("src"), + }; + state.expanded_dirs.insert(tracked_key.clone(), false); + + let tracked_entries = state.build_tree_entries( + Section::Tracked, + vec![entry("src/tracked.rs", StatusCode::Modified.worktree())], + &mut seen_directories, + ); + let new_entries = state.build_tree_entries( + Section::New, + vec![entry("src/new.rs", FileStatus::Untracked)], + &mut seen_directories, + ); + + assert_eq!(state.expanded_dirs.get(&tracked_key), Some(&false)); + assert_eq!(state.expanded_dirs.get(&new_key), Some(&true)); + assert!(matches!( + tracked_entries.first(), + Some((GitListEntry::Directory(entry), _)) if !entry.expanded + )); + assert!(matches!( + new_entries.first(), + Some((GitListEntry::Directory(entry), _)) if entry.expanded + )); + } + fn register_git_commit_language(project: &Entity, cx: &mut VisualTestContext) { project.read_with(cx, |project, _| { project.languages().add(Arc::new(language::Language::new( @@ -8318,93 +9101,1128 @@ mod tests { .expect("foo.rs should exist in the tree view changes list"); panel.update_in(&mut cx, |panel, window, cx| { - panel.selected_entry = Some(entry_index); - panel.view_file(&ViewFile, window, cx); + panel.selected_entry = Some(entry_index); + panel.view_file(&ViewFile, window, cx); + }); + cx.run_until_parked(); + + assert_editor_opened_with_path(&workspace, Path::new("src/a/foo.rs"), &mut cx); + } + + async fn history_panel_for_project( + fs: Arc, + cx: &mut TestAppContext, + ) -> Entity { + let project = Project::test(fs, [Path::new(path!("/root/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); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + cx.executor().run_until_parked(); + + let panel = workspace.update_in(cx, GitPanel::new); + panel.update_in(cx, |panel, window, cx| { + panel.activate_history_tab(&ActivateHistoryTab, window, cx); + }); + cx.run_until_parked(); + panel + } + + async fn wait_for_commit_history_to_settle(panel: &Entity, cx: &mut TestAppContext) { + cx.condition(panel, |panel, _| { + !matches!(panel.commit_history, CommitHistory::Loading) + }) + .await; + } + + #[test] + fn test_format_git_error_toast_message_prefers_raw_rpc_message() { + let rpc_error = RpcError::from_proto( + &proto::Error { + message: + "Your local changes to the following files would be overwritten by merge\n" + .to_string(), + code: proto::ErrorCode::Internal as i32, + tags: Default::default(), + }, + "Pull", + ); + + let message = format_git_error_toast_message(&rpc_error); + assert_eq!( + message, + "Your local changes to the following files would be overwritten by merge" + ); + } + + #[test] + fn test_format_git_error_toast_message_prefers_raw_rpc_message_when_wrapped() { + let rpc_error = RpcError::from_proto( + &proto::Error { + message: + "Your local changes to the following files would be overwritten by merge\n" + .to_string(), + code: proto::ErrorCode::Internal as i32, + tags: Default::default(), + }, + "Pull", + ); + let wrapped = rpc_error.context("sending pull request"); + + let message = format_git_error_toast_message(&wrapped); + assert_eq!( + message, + "Your local changes to the following files would be overwritten by merge" + ); + } + + #[gpui::test] + async fn test_history_tab_stops_loading_for_unborn_branch(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": { ".git": {} } })) + .await; + + let dot_git = Path::new(path!("/root/project/.git")); + fs.set_branch_name(dot_git, Some("main")); + fs.with_git_state(dot_git, false, |state| { + state.refs.remove("HEAD"); + }) + .unwrap(); + + let panel = history_panel_for_project(fs.clone(), cx).await; + + wait_for_commit_history_to_settle(&panel, cx).await; + panel.read_with(cx, |panel, _| { + assert_eq!(panel.commit_history, CommitHistory::Loaded(Rc::from([]))); + }); + } + + #[gpui::test] + async fn test_history_tab_loads_detached_head(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": { ".git": {} } })) + .await; + + let dot_git = Path::new(path!("/root/project/.git")); + let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap(); + fs.with_git_state(dot_git, false, |state| { + state.current_branch_name = None; + state.refs.insert("HEAD".into(), sha.to_string()); + state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData { + sha, + parents: SmallVec::new(), + ref_names: Vec::new(), + })]; + }) + .unwrap(); + + let panel = history_panel_for_project(fs.clone(), cx).await; + + wait_for_commit_history_to_settle(&panel, cx).await; + panel.read_with(cx, |panel, _| { + assert_eq!( + panel.commit_history, + CommitHistory::Loaded(Rc::from([CommitHistoryEntry { + sha, + tag_names: Vec::new(), + }])) + ); + }); + } + + #[gpui::test] + async fn test_history_tab_surfaces_load_error(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": { ".git": {} } })) + .await; + + let dot_git = Path::new(path!("/root/project/.git")); + let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap(); + fs.with_git_state(dot_git, false, |state| { + state.current_branch_name = None; + state.refs.insert("HEAD".into(), sha.to_string()); + state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData { + sha, + parents: SmallVec::new(), + ref_names: Vec::new(), + })]; + }) + .unwrap(); + fs.set_graph_error(dot_git, Some("simulated git log failure".into())); + + let panel = history_panel_for_project(fs.clone(), cx).await; + + wait_for_commit_history_to_settle(&panel, cx).await; + panel.read_with(cx, |panel, _| { + assert!(matches!(panel.commit_history, CommitHistory::Error(_))); + }); + } + + #[gpui::test] + async fn test_history_tab_without_repository(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": {} })).await; + + let panel = history_panel_for_project(fs.clone(), cx).await; + + panel.read_with(cx, |panel, _| { + assert_eq!(panel.commit_history, CommitHistory::Loading); + }); + } + + #[test] + fn test_commit_history_from_response() { + let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap(); + let error = SharedString::from("git log failed"); + let entries: Rc<[CommitHistoryEntry]> = Rc::from([CommitHistoryEntry { + sha, + tag_names: Vec::new(), + }]); + let no_entries: Rc<[CommitHistoryEntry]> = Rc::from([]); + + // Commits win even while the fetch task still reports `is_loading`. + assert_eq!( + commit_history_from_response(entries.clone(), true, None), + CommitHistory::Loaded(entries.clone()) + ); + assert_eq!( + commit_history_from_response(entries.clone(), false, None), + CommitHistory::Loaded(entries.clone()) + ); + // Commits also take precedence over a concurrently reported error. + assert_eq!( + commit_history_from_response(entries.clone(), true, Some(error.clone())), + CommitHistory::Loaded(entries) + ); + + // With no commits a terminal error beats the loading state. + assert_eq!( + commit_history_from_response(no_entries.clone(), true, Some(error.clone())), + CommitHistory::Error(error.clone()) + ); + assert_eq!( + commit_history_from_response(no_entries.clone(), false, Some(error.clone())), + CommitHistory::Error(error) + ); + + // When no commits and no error, loading vs. finished-empty hinges on `is_loading`. + assert_eq!( + commit_history_from_response(no_entries.clone(), true, None), + CommitHistory::Loading + ); + assert_eq!( + commit_history_from_response(no_entries.clone(), false, None), + CommitHistory::Loaded(no_entries) + ); + } + + #[gpui::test] + async fn test_entry_worktree_paths(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "zed": { + ".git": {}, + "crates": { + "gpui": { + "gpui.rs": "fn main() {}" + }, + "util": { + "util.rs": "fn do_it() {}" + } + } + }, + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/zed/.git")), + &[ + ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()), + ("crates/util/util.rs", StatusCode::Modified.worktree()), + ], + ); + let project = + Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], 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); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + 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; + + let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); + pretty_assertions::assert_eq!( + entries, + [ + GitListEntry::Header(GitHeaderEntry { + header: Section::Tracked + }), + GitListEntry::Status(GitStatusEntry { + repo_path: repo_path("crates/gpui/gpui.rs"), + status: StatusCode::Modified.worktree(), + staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), + }), + GitListEntry::Status(GitStatusEntry { + repo_path: repo_path("crates/util/util.rs"), + status: StatusCode::Modified.worktree(), + staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), + },), + ], + ); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); + pretty_assertions::assert_eq!( + entries, + [ + GitListEntry::Header(GitHeaderEntry { + header: Section::Tracked + }), + GitListEntry::Status(GitStatusEntry { + repo_path: repo_path("crates/gpui/gpui.rs"), + status: StatusCode::Modified.worktree(), + staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), + }), + GitListEntry::Status(GitStatusEntry { + repo_path: repo_path("crates/util/util.rs"), + status: StatusCode::Modified.worktree(), + staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), + },), + ], + ); + } + + #[gpui::test] + async fn test_discard_prompt_escapes_markdown_in_file_name(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "__somefile__": "modified\n", + }, + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[("__somefile__", StatusCode::Modified.worktree())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/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); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + 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; + + panel.update_in(cx, |panel, window, cx| { + panel.selected_entry = Some(1); + panel.revert_selected(&git::RestoreFile::default(), window, cx); + }); + + let (message, _detail) = cx + .pending_prompt() + .expect("discard should show a confirmation prompt"); + + assert_eq!( + message, + "Are you sure you want to discard changes to `__somefile__`?" + ); + } + + #[gpui::test] + async fn test_group_by_staging_section_membership_and_order(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "conflicted content", + "new.rs": "new content", + "partial.rs": "partial content", + "partial_new.rs": "partial new content", + "staged.rs": "staged content", + "unstaged.rs": "unstaged content", + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.rs", + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + ), + ("new.rs", FileStatus::Untracked), + ( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ( + "partial_new.rs", + TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state.head_contents.insert( + repo_path("partial.rs"), + "head one\nhead two\nhead three\nhead four".into(), + ); + state + .index_contents + .insert(repo_path("partial.rs"), "index one\nindex two".into()); + }) + .expect("fake repository should exist"); + + 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 mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let entries = panel.read_with(&mut cx, |panel, _| { + assert_eq!(panel.entry_count, 6); + assert_eq!( + panel + .change_entries_by_path() + .filter(|entry| entry.status.is_created()) + .map(|entry| &*entry.repo_path) + .sorted() + .collect::>(), + [rel_path("new.rs"), rel_path("partial_new.rs")] + ); + + let partial_path = repo_path("partial.rs"); + let projections = panel + .projected_entries_by_path + .get(&partial_path) + .expect("partially staged entry should have projections"); + assert_eq!( + projections.as_slice(), + &[ + ProjectedChangeEntry { + section: Section::Staged, + index: 3, + }, + ProjectedChangeEntry { + section: Section::Unstaged, + index: 8, + }, + ] + ); + assert_eq!( + panel.stage_intent_for_entry_index(projections[0].index), + StageIntent::Unstage + ); + assert_eq!( + panel.stage_intent_for_entry_index(projections[1].index), + StageIntent::Stage + ); + assert_eq!( + panel.entries[projections[0].index] + .status_entry() + .and_then(|entry| entry.diff_stat), + Some(DiffStat { + added: 2, + deleted: 4, + }) + ); + assert_eq!( + panel.entries[projections[1].index] + .status_entry() + .and_then(|entry| entry.diff_stat), + Some(DiffStat { + added: 1, + deleted: 2, + }) + ); + panel.entries.clone() + }); + + #[rustfmt::skip] + pretty_assertions::assert_matches!( + entries.as_slice(), + &[ + Header(GitHeaderEntry { header: Section::Conflict }), + Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), + Header(GitHeaderEntry { header: Section::Staged }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Staged, .. }), + Header(GitHeaderEntry { header: Section::Unstaged }), + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + ], + ); + assert_entry_paths( + &entries, + &[ + None, + Some("conflict.rs"), + None, + Some("partial.rs"), + Some("partial_new.rs"), + Some("staged.rs"), + None, + Some("new.rs"), + Some("partial.rs"), + Some("partial_new.rs"), + Some("unstaged.rs"), + ], + ); + + let worktree_id = + cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); + panel.update_in(&mut cx, |panel, window, cx| { + panel.select_entry_by_path( + ProjectPath { + worktree_id, + path: rel_path("partial.rs").into_arc(), + }, + window, + cx, + ); + }); + panel.read_with(&cx, |panel, _| { + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged) + ); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged); + panel.select_entry_by_path( + ProjectPath { + worktree_id, + path: rel_path("partial.rs").into_arc(), + }, + window, + cx, + ); + }); + panel.read_with(&cx, |panel, _| { + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged) + ); + }); + + panel.update_in(&mut cx, |panel, _window, _cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged); + }); + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.rs", + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + ), + ("new.rs", FileStatus::Untracked), + ("partial.rs", StatusCode::Modified.worktree()), + ( + "partial_new.rs", + TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&cx, |panel, _| { + let selected_entry = panel + .get_selected_entry() + .and_then(GitListEntry::status_entry) + .expect("selected change should remain selected"); + assert_eq!(selected_entry.repo_path, repo_path("partial.rs")); + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged) + ); + }); + } + + #[gpui::test] + async fn test_staging_conflict_mark_resolved_transition(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n", + }), + ) + .await; + + let unresolved_status = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[("conflict.rs", unresolved_status)], + ); + + 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, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let conflict_entry = panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + Header(GitHeaderEntry { + header: Section::Staged + }), + EmptySection(Section::Staged), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + EmptySection(Section::Unstaged), + ], + ); + panel + .entries + .get(1) + .and_then(GitListEntry::status_entry) + .cloned() + .expect("conflict entry should exist") + }); + + panel.update_in(&mut cx, |panel, _window, cx| { + panel.change_file_stage(true, vec![conflict_entry.clone()], cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _| { + assert!(matches!( + panel.entries.as_slice(), + [ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + Header(GitHeaderEntry { + header: Section::Staged + }), + EmptySection(Section::Staged), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + EmptySection(Section::Unstaged), + ] + )); + }); + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[("conflict.rs", FileStatus::index(StatusCode::Modified))], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Staged + }), + Status(GitStatusEntry { + staging: StageStatus::Staged, + .. + }), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + EmptySection(Section::Unstaged), + ], + ); + assert_eq!(panel.entry_count, 1); + }); + } + + #[gpui::test] + async fn test_resolved_conflict_is_locked_against_unstaging(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n", + "staged.rs": "staged content", + "unstaged.rs": "unstaged content", + }), + ) + .await; + + let unresolved_status = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("conflict.rs", unresolved_status), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + // With MERGE_HEAD present (an in-progress merge), a resolved conflict + // keeps rendering under the Conflict section instead of moving to Staged. + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state.refs.insert("MERGE_HEAD".into(), "merge-sha".into()); + }) + .unwrap(); + + 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, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + fn stage_status_of( + panel: &Entity, + cx: &VisualTestContext, + path: &str, + ) -> StageStatus { + panel.read_with(cx, |panel, cx| { + let repo = panel + .active_repository + .as_ref() + .expect("active repository should exist") + .read(cx); + let entry = panel + .change_entries_by_path() + .find(|entry| entry.repo_path == repo_path(path)) + .expect("entry should exist") + .clone(); + GitPanel::stage_status_for_entry(&entry, repo) + }) + } + + let conflict_entry = panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + Header(GitHeaderEntry { + header: Section::Staged + }), + Status(GitStatusEntry { + staging: StageStatus::Staged, + .. + }), + Header(GitHeaderEntry { + header: Section::Unstaged + }), + Status(GitStatusEntry { + staging: StageStatus::Unstaged, + .. + }), + ], + ); + panel + .entries + .get(1) + .and_then(GitListEntry::status_entry) + .cloned() + .expect("conflict entry should exist") + }); + + // Resolve the conflict: simulate what `git add` does to the status. + panel.update_in(&mut cx, |panel, window, cx| { + panel.toggle_staged_for_entry( + &GitListEntry::Status(conflict_entry.clone()), + StageIntent::Toggle, + window, + cx, + ); + }); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("conflict.rs", FileStatus::index(StatusCode::Modified)), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + // The resolved conflict stays in the Conflict section, staged. + panel.read_with(&cx, |panel, cx| { + assert_eq!( + panel.section_for_entry_index( + panel + .entry_by_path(&repo_path("conflict.rs")) + .expect("conflict entry should exist") + ), + Some(Section::Conflict) + ); + assert!( + panel.is_resolved_conflict( + panel.entry_by_path(&repo_path("conflict.rs")).unwrap(), + cx + ) + ); + }); + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged + ); + + // The keyboard toggle must not unstage a resolved conflict. + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = panel.entry_by_path(&repo_path("conflict.rs")); + panel.toggle_staged_for_selected(&ToggleStaged, window, cx); }); cx.run_until_parked(); - - assert_editor_opened_with_path(&workspace, Path::new("src/a/foo.rs"), &mut cx); - } - - #[test] - fn test_format_git_error_toast_message_prefers_raw_rpc_message() { - let rpc_error = RpcError::from_proto( - &proto::Error { - message: - "Your local changes to the following files would be overwritten by merge\n" - .to_string(), - code: proto::ErrorCode::Internal as i32, - tags: Default::default(), - }, - "Pull", + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged ); - let message = format_git_error_toast_message(&rpc_error); + // "Unstage All" on the Staged header must skip resolved conflicts while + // still unstaging regular staged files. + panel.update_in(&mut cx, |panel, window, cx| { + panel.toggle_staged_for_entry( + &GitListEntry::Header(GitHeaderEntry { + header: Section::Staged, + }), + StageIntent::Unstage, + window, + cx, + ); + }); + cx.run_until_parked(); assert_eq!( - message, - "Your local changes to the following files would be overwritten by merge" + stage_status_of(&panel, &cx, "staged.rs"), + StageStatus::Unstaged ); - } - - #[test] - fn test_format_git_error_toast_message_prefers_raw_rpc_message_when_wrapped() { - let rpc_error = RpcError::from_proto( - &proto::Error { - message: - "Your local changes to the following files would be overwritten by merge\n" - .to_string(), - code: proto::ErrorCode::Internal as i32, - tags: Default::default(), - }, - "Pull", + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged ); - let wrapped = rpc_error.context("sending pull request"); - let message = format_git_error_toast_message(&wrapped); + // A shift-click range sweep anchored at the conflict must skip it too. + let staged_entry = panel.read_with(&cx, |panel, _| { + panel + .change_entries_by_path() + .find(|entry| entry.repo_path == repo_path("staged.rs")) + .cloned() + .expect("staged entry should exist") + }); + panel.update_in(&mut cx, |panel, _window, cx| { + panel.change_file_stage(true, vec![staged_entry], cx); + }); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.update_in(&mut cx, |panel, _window, cx| { + panel.set_bulk_staging_anchor(repo_path("conflict.rs"), cx); + let last_index = panel.entries.len() - 1; + panel.stage_bulk(last_index, false, cx); + }); + cx.run_until_parked(); assert_eq!( - message, - "Your local changes to the following files would be overwritten by merge" + stage_status_of(&panel, &cx, "staged.rs"), + StageStatus::Unstaged + ); + assert_eq!( + stage_status_of(&panel, &cx, "conflict.rs"), + StageStatus::Staged ); } #[gpui::test] - async fn test_entry_worktree_paths(cx: &mut TestAppContext) { + async fn test_group_by_staging_primary_action_stages_partially_staged_files( + cx: &mut TestAppContext, + ) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( - "/root", + path!("/project"), json!({ - "zed": { - ".git": {}, - "crates": { - "gpui": { - "gpui.rs": "fn main() {}" - }, - "util": { - "util.rs": "fn do_it() {}" - } - } - }, + ".git": {}, + "partial.rs": "partial content", + "staged.rs": "staged content", }), ) .await; fs.set_status_for_repo( - Path::new(path!("/root/zed/.git")), + path!("/project/.git").as_ref(), &[ - ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()), - ("crates/util/util.rs", StatusCode::Modified.worktree()), + ( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), ], ); - let project = - Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await; + 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 mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); cx.read(|cx| { project @@ -8421,103 +10239,59 @@ mod tests { cx.executor().run_until_parked(); - 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; - - let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); - pretty_assertions::assert_eq!( - entries, - [ - GitListEntry::Header(GitHeaderEntry { - header: Section::Tracked - }), - GitListEntry::Status(GitStatusEntry { - repo_path: repo_path("crates/gpui/gpui.rs"), - status: StatusCode::Modified.worktree(), - staging: StageStatus::Unstaged, - diff_stat: Some(DiffStat { - added: 1, - deleted: 1, - }), - }), - GitListEntry::Status(GitStatusEntry { - repo_path: repo_path("crates/util/util.rs"), - status: StatusCode::Modified.worktree(), - staging: StageStatus::Unstaged, - diff_stat: Some(DiffStat { - added: 1, - deleted: 1, - }), - },), - ], - ); + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; - let handle = cx.update_window_entity(&panel, |panel, _, _| { - std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + panel.read_with(&mut cx, |panel, _| { + assert_eq!(panel.entry_count, 2); + assert_eq!(panel.total_staged_count(), panel.entry_count); + assert!(panel.has_unstaged_changes()); + assert!(panel.primary_changes_action_stages()); }); - cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); - handle.await; - let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); - pretty_assertions::assert_eq!( - entries, - [ - GitListEntry::Header(GitHeaderEntry { - header: Section::Tracked - }), - GitListEntry::Status(GitStatusEntry { - repo_path: repo_path("crates/gpui/gpui.rs"), - status: StatusCode::Modified.worktree(), - staging: StageStatus::Unstaged, - diff_stat: Some(DiffStat { - added: 1, - deleted: 1, - }), - }), - GitListEntry::Status(GitStatusEntry { - repo_path: repo_path("crates/util/util.rs"), - status: StatusCode::Modified.worktree(), - staging: StageStatus::Unstaged, - diff_stat: Some(DiffStat { - added: 1, - deleted: 1, - }), - },), - ], - ); } #[gpui::test] - async fn test_discard_prompt_escapes_markdown_in_file_name(cx: &mut TestAppContext) { + async fn test_group_by_staging_open_diff_uses_section_diff(cx: &mut TestAppContext) { init_test(cx); + cx.update(search::buffer_search::init); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( - "/root", + path!("/project"), json!({ - "project": { - ".git": {}, - "__somefile__": "modified\n", - }, + ".git": {}, + "partial.rs": "partial content", }), ) .await; fs.set_status_for_repo( - Path::new(path!("/root/project/.git")), - &[("__somefile__", StatusCode::Modified.worktree())], + path!("/project/.git").as_ref(), + &[( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + )], ); - let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + 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()) + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); - let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); cx.read(|cx| { project @@ -8531,30 +10305,96 @@ mod tests { .scan_complete() }) .await; - cx.executor().run_until_parked(); - let panel = workspace.update_in(cx, GitPanel::new); + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; - let handle = cx.update_window_entity(&panel, |panel, _, _| { - std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged); + panel.open_diff(&menu::Confirm, window, cx); }); - cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); - handle.await; + cx.run_until_parked(); - panel.update_in(cx, |panel, window, cx| { - panel.selected_entry = Some(1); - panel.revert_selected(&git::RestoreFile::default(), window, cx); + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + assert_eq!(workspace.items_of_type::(cx).count(), 0); }); - let (message, _detail) = cx - .pending_prompt() - .expect("discard should show a confirmation prompt"); + panel.update_in(&mut cx, |panel, window, cx| { + panel.open_solo_diff(&menu::SecondaryConfirm, window, cx); + }); + cx.run_until_parked(); + let search_bar = workspace.update_in(&mut cx, |workspace, window, cx| { + let search_bar = cx.new(|cx| BufferSearchBar::new(None, window, cx)); + workspace.active_pane().update(cx, |pane, cx| { + pane.toolbar().update(cx, |toolbar, cx| { + toolbar.add_item(search_bar.clone(), window, cx) + }); + }); + search_bar + }); + + let split_editor = workspace.read_with(&cx, |workspace, cx| { + let solo_diff = workspace + .active_item_as::(cx) + .expect("SoloDiffView should be active"); + let searchable = solo_diff + .read(cx) + .as_searchable(&solo_diff, cx) + .expect("SoloDiffView should expose its editor to buffer search"); + let split_editor = searchable + .act_as_type(TypeId::of::(), cx) + .and_then(|entity| entity.downcast::().ok()) + .expect("the split editor should be the searchable item"); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + split_editor + }); + + let mut search_bar_events = cx.events::(&search_bar); + cx.dispatch_action(Deploy::find()); + cx.run_until_parked(); + cx.read(|cx| assert!(!search_bar.read(cx).is_dismissed())); assert_eq!( - message, - "Are you sure you want to discard changes to `__somefile__`?" + search_bar_events + .try_recv() + .expect("search bar location event"), + ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary) ); + + search_bar + .update_in(&mut cx, |search_bar, window, cx| { + search_bar.search("partial", None, false, window, cx) + }) + .await + .expect("buffer search should complete"); + + let focused_editor = cx.read(|cx| split_editor.read(cx).focused_editor().clone()); + focused_editor.update_in(&mut cx, |editor, _window, cx| { + // The diff editor's snapshot includes the deleted base row of the + // expanded hunk, so the query matches both the current line and + // the deleted line. + assert_eq!(editor.search_background_highlights(cx).len(), 2); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged); + panel.open_diff(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + }); } #[gpui::test] @@ -8655,7 +10495,7 @@ mod tests { let second_status_entry = entries[3].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); panel.update_in(cx, |panel, window, cx| { @@ -8704,7 +10544,7 @@ mod tests { let third_status_entry = entries[4].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&third_status_entry, window, cx); + panel.toggle_staged_for_entry(&third_status_entry, StageIntent::Toggle, window, cx); }); panel.update_in(cx, |panel, window, cx| { @@ -8866,7 +10706,7 @@ mod tests { let second_status_entry = entries[3].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); cx.update(|_window, cx| { @@ -8934,7 +10774,7 @@ mod tests { let third_status_entry = entries[4].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&third_status_entry, window, cx); + panel.toggle_staged_for_entry(&third_status_entry, StageIntent::Toggle, window, cx); }); panel.update_in(cx, |panel, window, cx| { @@ -9742,14 +11582,14 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false)); }); let worktree_id = cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); let project_path = ProjectPath { worktree_id, - path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(), + path: RelPath::from_unix_str("src/a/foo.rs").unwrap().into_arc(), }; panel.update_in(cx, |panel, window, cx| { @@ -9761,7 +11601,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(true)); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true)); let selected_ix = panel.selected_entry.expect("selection should be set"); assert!(state.logical_indices.contains(&selected_ix)); @@ -9870,7 +11710,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&foo_key.path).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false)); let foo_idx = panel .entries @@ -10077,7 +11917,7 @@ mod tests { let first_status_entry = entries[1].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&first_status_entry, window, cx); + panel.toggle_staged_for_entry(&first_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| { @@ -10114,7 +11954,7 @@ mod tests { let second_status_entry = entries[3].clone(); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| { @@ -10151,7 +11991,7 @@ mod tests { assert!(message.is_none()); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&first_status_entry, window, cx); + panel.toggle_staged_for_entry(&first_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| { @@ -10187,7 +12027,7 @@ mod tests { assert_eq!(message, Some("Create untracked".to_string())); panel.update_in(cx, |panel, window, cx| { - panel.toggle_staged_for_entry(&second_status_entry, window, cx); + panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx); }); cx.read(|cx| { @@ -10221,6 +12061,19 @@ mod tests { // "Update tracked" let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); assert_eq!(message, Some("Update tracked".to_string())); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }); + }); + }); + await_git_panel_entries(&panel, cx).await; + + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert_eq!(message, Some("Update tracked".to_string())); } #[test] diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index c972deb08568f1..5fc565dd43f2e6 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -12,7 +12,10 @@ use ui::{ }; use workspace::{ModalView, Workspace, pane}; -use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes, ForceDeleteBranch}; +use crate::branch_picker::{ + self, BranchList, CycleBranchFilter, DeleteBranch, ForceDeleteBranch, ShowAllBranches, + ShowLocalBranches, ShowRemoteBranches, +}; use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList}; actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]); @@ -308,15 +311,55 @@ impl GitPicker { } } - fn handle_filter_remotes( + fn set_active_branch_filter( + &mut self, + branch_filter: branch_picker::BranchFilter, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(branch_list) = &self.branch_list { + branch_list.update(cx, |list, cx| { + list.set_branch_filter(branch_filter, window, cx); + }); + } + } + + fn handle_show_all_branches( + &mut self, + _: &ShowAllBranches, + window: &mut Window, + cx: &mut Context, + ) { + self.set_active_branch_filter(branch_picker::BranchFilter::All, window, cx); + } + + fn handle_show_local_branches( + &mut self, + _: &ShowLocalBranches, + window: &mut Window, + cx: &mut Context, + ) { + self.set_active_branch_filter(branch_picker::BranchFilter::Local, window, cx); + } + + fn handle_show_remote_branches( + &mut self, + _: &ShowRemoteBranches, + window: &mut Window, + cx: &mut Context, + ) { + self.set_active_branch_filter(branch_picker::BranchFilter::Remote, window, cx); + } + + fn handle_cycle_branch_filter( &mut self, - _: &FilterRemotes, + _: &CycleBranchFilter, window: &mut Window, cx: &mut Context, ) { if let Some(branch_list) = &self.branch_list { branch_list.update(cx, |list, cx| { - list.handle_filter(&FilterRemotes, window, cx); + list.cycle_branch_filter(window, cx); }); } } @@ -377,7 +420,14 @@ impl Render for GitPicker { .elevation_3(cx) .overflow_hidden() .when(self.popover_style, |el| { - el.on_mouse_down_out(cx.listener(|_, _, _, cx| { + el.on_mouse_down_out(cx.listener(|this, _, _, cx| { + if this + .branch_list + .as_ref() + .is_some_and(|branch_list| branch_list.read(cx).branch_filter_menu_open(cx)) + { + return; + } cx.emit(DismissEvent); })) }) @@ -421,7 +471,10 @@ impl Render for GitPicker { .when(self.tab == GitPickerTab::Branches, |el| { el.on_action(cx.listener(Self::handle_delete_branch)) .on_action(cx.listener(Self::handle_force_delete_branch)) - .on_action(cx.listener(Self::handle_filter_remotes)) + .on_action(cx.listener(Self::handle_show_all_branches)) + .on_action(cx.listener(Self::handle_show_local_branches)) + .on_action(cx.listener(Self::handle_show_remote_branches)) + .on_action(cx.listener(Self::handle_cycle_branch_filter)) }) .when(self.tab == GitPickerTab::Stashes, |el| { el.on_action(cx.listener(Self::handle_drop_stash)) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index a5dc98c5049cf0..39e9299bc40cb0 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -2,10 +2,6 @@ use anyhow::anyhow; use commit_modal::CommitModal; use editor::{Editor, actions::DiffClipboardWithSelectionData}; -use ui::{ - Color, Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, - Styled, StyledExt, div, h_flex, rems, v_flex, -}; use workspace::{Toast, notifications::NotificationId}; mod blame_ui; @@ -23,7 +19,7 @@ use menu::{Cancel, Confirm}; use project::git_store::Repository; use project_diff::ProjectDiff; use time::OffsetDateTime; -use ui::prelude::*; +use ui::{ButtonLike, ContextMenu, ElevationIndex, PopoverMenuHandle, TintColor, prelude::*}; use workspace::{ ModalView, OpenMode, Workspace, notifications::{DetachAndPromptErr, NotifyTaskExt}, @@ -38,12 +34,15 @@ use crate::{ }; mod askpass_modal; +pub mod branch_diff; pub mod branch_picker; +mod commit_context_menu; mod commit_modal; pub mod commit_tooltip; pub mod commit_view; mod conflict_view; pub mod created_worktrees; +mod diff_multibuffer; pub mod file_diff_view; pub mod git_graph; pub mod git_panel; @@ -56,8 +55,10 @@ pub mod project_diff; pub(crate) mod remote_output; pub mod repository_selector; pub mod solo_diff_view; +pub mod staged_diff; pub mod stash_picker; pub mod text_diff_view; +pub mod unstaged_diff; pub mod worktree_names; pub mod worktree_picker; pub mod worktree_service; @@ -91,6 +92,9 @@ pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, cx| { ProjectDiff::register(workspace, cx); + staged_diff::StagedDiff::register(workspace, cx); + unstaged_diff::UnstagedDiff::register(workspace, cx); + branch_diff::BranchDiff::register(workspace, cx); CommitModal::register(workspace); git_panel::register(workspace); repository_selector::register(workspace); @@ -766,6 +770,7 @@ fn render_remote_button( keybinding_target: Option, show_fetch_button: bool, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> Option { let id = id.into(); let upstream = branch.upstream.as_ref(); @@ -778,6 +783,7 @@ fn render_remote_button( keybinding_target, id, in_progress_operation, + menu_handle, )), (0, 0) => None, (ahead, 0) => Some(remote_button::render_push_button( @@ -785,6 +791,7 @@ fn render_remote_button( id, ahead, in_progress_operation, + menu_handle, )), (ahead, behind) => Some(remote_button::render_pull_button( keybinding_target, @@ -792,6 +799,7 @@ fn render_remote_button( ahead, behind, in_progress_operation, + menu_handle, )), }, Some(Upstream { @@ -801,11 +809,13 @@ fn render_remote_button( keybinding_target, id, in_progress_operation, + menu_handle, )), None => Some(remote_button::render_publish_button( keybinding_target, id, in_progress_operation, + menu_handle, )), } } @@ -813,12 +823,16 @@ fn render_remote_button( mod remote_button { use crate::git_panel::RemoteOperationKind; use gpui::{Action, Anchor, AnyView, ClickEvent, FocusHandle}; - use ui::{CommonAnimationExt, ContextMenu, PopoverMenu, SplitButton, Tooltip, prelude::*}; + use ui::{ + ButtonLike, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu, + PopoverMenuHandle, SplitButton, Tooltip, prelude::*, + }; pub fn render_fetch_button( keybinding_target: Option, id: SharedString, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -828,6 +842,7 @@ mod remote_button { Some(IconName::ArrowCircle), keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Fetch), cx); }, @@ -848,6 +863,7 @@ mod remote_button { id: SharedString, ahead: u32, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -857,6 +873,7 @@ mod remote_button { None, keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, @@ -878,6 +895,7 @@ mod remote_button { ahead: u32, behind: u32, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -887,6 +905,7 @@ mod remote_button { None, keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Pull), cx); }, @@ -906,6 +925,7 @@ mod remote_button { keybinding_target: Option, id: SharedString, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -915,6 +935,7 @@ mod remote_button { Some(IconName::ExpandUp), keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, @@ -934,6 +955,7 @@ mod remote_button { keybinding_target: Option, id: SharedString, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -943,6 +965,7 @@ mod remote_button { Some(IconName::ExpandUp), keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, @@ -986,18 +1009,16 @@ mod remote_button { fn render_git_action_menu( id: impl Into, keybinding_target: Option, + menu_handle: PopoverMenuHandle, ) -> impl IntoElement { + let menu_open = menu_handle.is_deployed(); + PopoverMenu::new(id.into()) - .trigger( - ui::ButtonLike::new_rounded_right("split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::None) - .child( - div() - .px_1() - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), - ) + .trigger(crate::render_split_button_chevron_trigger( + "split-button-right", + menu_open, + )) + .with_handle(menu_handle) .menu(move |window, cx| { Some(ContextMenu::build(window, cx, |context_menu, _, _| { context_menu @@ -1015,6 +1036,10 @@ mod remote_button { })) }) .anchor(Anchor::TopRight) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) } #[allow(clippy::too_many_arguments)] @@ -1026,6 +1051,7 @@ mod remote_button { left_icon: Option, keybinding_target: Option, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, ) -> SplitButton { @@ -1033,7 +1059,6 @@ mod remote_button { h_flex() .ml_neg_px() .h(rems(0.875)) - .items_center() .overflow_hidden() .px_0p5() .child( @@ -1046,58 +1071,57 @@ mod remote_button { let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0); let is_in_progress = in_progress_operation.is_some(); - let left = ui::ButtonLike::new_rounded_left(ElementId::Name( - format!("split-button-left-{}", id).into(), - )) - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::Compact) - .disabled(is_in_progress) - .when(should_render_counts, |this| { - this.child( - h_flex() - .ml_neg_0p5() - .when(behind_count > 0, |this| { - this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall)) - .child(count(behind_count)) - }) - .when(ahead_count > 0, |this| { - this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall)) - .child(count(ahead_count)) - }), + let left = ButtonLike::new_rounded_left(format!("split-button-left-{}", id)) + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) + .disabled(is_in_progress) + .when(should_render_counts, |this| { + this.child( + h_flex() + .ml_neg_0p5() + .when(behind_count > 0, |this| { + this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall)) + .child(count(behind_count)) + }) + .when(ahead_count > 0, |this| { + this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall)) + .child(count(ahead_count)) + }), + ) + }) + .when_some(left_icon, |this, left_icon| { + this.map(|this| { + if is_in_progress { + this.child( + Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Disabled) + .with_rotate_animation(2), + ) + } else { + this.child(Icon::new(left_icon).size(IconSize::XSmall)) + } + }) + }) + .child( + Label::new(left_label) + .size(LabelSize::Small) + .when(is_in_progress, |this| this.color(Color::Disabled)) + .mr_0p5(), ) - }) - .when_some(left_icon, |this, left_icon| { - this.map(|this| { - if is_in_progress { - this.child( - Icon::new(IconName::LoadCircle) - .size(IconSize::XSmall) - .color(Color::Disabled) - .with_rotate_animation(2), - ) + .on_click(left_on_click) + .tooltip(move |window, cx| { + if let Some(operation) = in_progress_operation { + Tooltip::simple(in_progress_tooltip(operation), cx) } else { - this.child(Icon::new(left_icon).size(IconSize::XSmall)) + tooltip(window, cx) } - }) - }) - .child( - Label::new(left_label) - .size(LabelSize::Small) - .when(is_in_progress, |this| this.color(Color::Disabled)) - .mr_0p5(), - ) - .on_click(left_on_click) - .tooltip(move |window, cx| { - if let Some(operation) = in_progress_operation { - Tooltip::simple(in_progress_tooltip(operation), cx) - } else { - tooltip(window, cx) - } - }); + }); let right = render_git_action_menu( - ElementId::Name(format!("split-button-right-{}", id).into()), + format!("split-button-right-{}", id), keybinding_target, + menu_handle, ) .into_any_element(); @@ -1105,6 +1129,25 @@ mod remote_button { } } +pub(crate) fn render_split_button_chevron_trigger( + id: impl Into, + menu_open: bool, +) -> ButtonLike { + let chevron_button_size = rems_from_px(20.); + let chevron_icon = if menu_open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + ButtonLike::new_rounded_right(id) + .layer(ElevationIndex::ModalSurface) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .width(chevron_button_size) + .height(chevron_button_size.into()) + .child(Icon::new(chevron_icon).size(IconSize::XSmall)) +} + /// A visual representation of a file's Git status. #[derive(IntoElement, RegisterComponent)] pub struct GitStatusIcon { diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index f8097e68f5c8d8..7834a7319fb777 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -1,7 +1,10 @@ use crate::file_diff_view::build_buffer_diff; use anyhow::Result; use buffer_diff::BufferDiff; -use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines}; +use editor::{ + Editor, EditorEvent, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + multibuffer_context_lines, +}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, IntoElement, Render, SharedString, Task, Window, @@ -91,7 +94,7 @@ fn register_entry( RelPath::new(rel, PathStyle::local()) .map(|r| r.into_owned().into()) .unwrap_or_else(|_| { - RelPath::new(Path::new("untitled"), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Unix) .unwrap() .into_owned() .into() @@ -102,10 +105,10 @@ fn register_entry( .new_path .file_name() .and_then(|n| n.to_str()) - .and_then(|s| RelPath::new(Path::new(s), PathStyle::Posix).ok()) + .and_then(|s| RelPath::new(Path::new(s), PathStyle::Unix).ok()) .map(|r| r.into_owned().into()) .unwrap_or_else(|| { - RelPath::new(Path::new("untitled"), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Unix) .unwrap() .into_owned() .into() @@ -196,14 +199,9 @@ impl MultiDiffView { let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx); - editor.start_temporary_diff_override(); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); editor.disable_diagnostics(cx); editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunks_as_unstaged(true, cx); - editor.set_render_diff_hunk_controls( - Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), - cx, - ); editor }); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 271853c615fed7..f0e13377a58cad 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1,57 +1,41 @@ use crate::{ - branch_picker, conflict_view, + diff_multibuffer::DiffMultibuffer, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, - git_panel_settings::GitPanelSettings, + staged_diff::StagedDiff, + unstaged_diff::UnstagedDiff, }; -use agent_settings::AgentSettings; -use anyhow::{Context as _, Result, anyhow}; -use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus}; -use collections::HashMap; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkSecondaryStatus; use editor::{ - Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, + Editor, EditorEvent, SplittableEditor, UncommittedDiffHunkDelegate, actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent}, - multibuffer_context_lines, - scroll::Autoscroll, -}; -use futures_lite::future::yield_now; -use git::repository::DiffType; - -use git::{ - Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath, - status::FileStatus, }; +use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext}; use gpui::{ - Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, - FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions, + Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render, + Subscription, Task, WeakEntity, actions, }; -use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; -use multi_buffer::{MultiBuffer, PathKey}; +use language::Capability; +use multi_buffer::MultiBuffer; use project::{ - ConflictSet, Project, ProjectPath, + Project, ProjectPath, git_store::{ Repository, - branch_diff::{self, BranchDiffEvent, DiffBase}, + diff_buffer_list::{self, DiffBase}, }, }; -use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; +use schemars::JsonSchema; +use serde::Deserialize; use std::any::{Any, TypeId}; -use std::collections::BTreeMap; use std::sync::Arc; -use theme::ActiveTheme; -use ui::{ - CommonAnimationExt as _, DiffStat, Divider, KeyBinding, PopoverMenu, Tooltip, prelude::*, - vertical_divider, -}; -use util::{ResultExt as _, rel_path::RelPath}; +use ui::{DiffStat, Divider, Tooltip, prelude::*}; use workspace::{ - CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, - ToolbarItemView, Workspace, + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, - notifications::NotifyTaskExt, searchable::SearchableItemHandle, }; -use zed_actions::agent::ReviewBranchDiff; -use ztracing::instrument; +use zed_actions::git as git_actions; actions!( git, @@ -60,9 +44,6 @@ actions!( Diff, /// Adds files to the git staging area. Add, - /// Shows the diff between the working directory and your default - /// branch (typically main or master). - BranchDiff, /// Opens a new agent thread with the branch diff for review. ReviewDiff, LeaderAndFollower, @@ -71,32 +52,37 @@ actions!( ] ); -struct BufferSubscriptions { - _diff: Entity, - _diff_subscription: Subscription, - _conflict_set: Entity, - _conflict_set_subscription: Subscription, -} +/// Shows the diff between the working directory and your default +/// branch (typically main or master). +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = git, name = "BranchDiff")] +pub(crate) struct DeployBranchDiff; pub struct ProjectDiff { project: Entity, - multibuffer: Entity, - branch_diff: Entity, - editor: Entity, - buffer_subscriptions: HashMap, BufferSubscriptions>, workspace: WeakEntity, - focus_handle: FocusHandle, - pending_scroll: Option, - review_comment_count: usize, - _task: Task>, - _subscription: Subscription, + diff: Entity, + _diff_observation: Subscription, } impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); - workspace.register_action(Self::deploy_branch_diff); - workspace.register_action(Self::compare_with_branch); + workspace.register_action( + |workspace, _: &git_actions::ViewUncommittedChanges, window, cx| { + Self::deploy_at(workspace, None, window, cx); + }, + ); + workspace.register_action( + |workspace, _: &git_actions::ViewUnstagedChanges, window, cx| { + UnstagedDiff::deploy_at(workspace, None, window, cx); + }, + ); + workspace.register_action( + |workspace, _: &git_actions::ViewStagedChanges, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }, + ); workspace.register_action(|workspace, _: &Add, window, cx| { Self::deploy(workspace, &Diff, window, cx); }); @@ -112,216 +98,6 @@ impl ProjectDiff { Self::deploy_at(workspace, None, window, cx) } - fn deploy_branch_diff( - workspace: &mut Workspace, - _: &BranchDiff, - window: &mut Window, - cx: &mut Context, - ) { - telemetry::event!("Git Branch Diff Opened"); - let project = workspace.project().clone(); - let Some(intended_repo) = project.read(cx).active_repository(cx) else { - let workspace = cx.entity().downgrade(); - window - .spawn(cx, async |_cx| { - let result: Result<()> = Err(anyhow!("No active repository")); - result - }) - .detach_and_notify_err(workspace, window, cx); - return; - }; - - let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true)); - let workspace = cx.entity(); - let workspace_weak = workspace.downgrade(); - window - .spawn(cx, async move |cx| { - let base_ref = default_branch - .await?? - .context("Could not determine default branch")?; - - workspace.update_in(cx, |workspace, window, cx| { - Self::deploy_branch_diff_with_base_ref( - workspace, - project, - intended_repo, - base_ref, - window, - cx, - ); - })?; - - anyhow::Ok(()) - }) - .detach_and_notify_err(workspace_weak, window, cx); - } - - fn compare_with_branch( - workspace: &mut Workspace, - _: &CompareWithBranch, - window: &mut Window, - cx: &mut Context, - ) { - let project = workspace.project().clone(); - let Some(repository) = project.read(cx).active_repository(cx) else { - let workspace = cx.entity().downgrade(); - window - .spawn(cx, async |_cx| { - let result: Result<()> = Err(anyhow!("No active repository")); - result - }) - .detach_and_notify_err(workspace, window, cx); - return; - }; - let selected_branch = workspace.active_item_as::(cx).and_then(|item| { - match item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => Some(base_ref.clone()), - DiffBase::Head => None, - } - }); - let workspace_handle = workspace.weak_handle(); - let on_select = Arc::new({ - let repository = repository.clone(); - let workspace = workspace_handle.clone(); - move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| { - let base_ref: SharedString = branch.name().to_owned().into(); - workspace - .update(cx, |workspace, cx| { - Self::deploy_branch_diff_with_base_ref( - workspace, - project.clone(), - repository.clone(), - base_ref, - window, - cx, - ); - }) - .ok(); - } - }); - - workspace.toggle_modal(window, cx, |window, cx| { - branch_picker::select_modal( - workspace_handle, - Some(repository), - selected_branch, - on_select, - window, - cx, - ) - }); - } - - fn deploy_branch_diff_with_base_ref( - workspace: &mut Workspace, - project: Entity, - intended_repo: Entity, - base_ref: SharedString, - window: &mut Window, - cx: &mut Context, - ) { - let existing = workspace.items_of_type::(cx).find(|item| { - let item = item.read(cx); - matches!( - item.diff_base(cx), - DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref - ) - }); - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - - let needs_switch = existing - .read(cx) - .branch_diff - .read(cx) - .repo() - .map_or(true, |current| { - current.read(cx).id != intended_repo.read(cx).id - }); - - if needs_switch { - existing.update(cx, |project_diff, cx| { - project_diff.branch_diff.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended_repo), cx); - }); - }); - } - - return; - } - - let workspace = cx.entity(); - let workspace_weak = workspace.downgrade(); - window - .spawn(cx, async move |cx| { - let this = cx - .update(|window, cx| { - Self::new_with_branch_base( - project, - workspace.clone(), - base_ref, - intended_repo, - window, - cx, - ) - })? - .await?; - workspace - .update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx); - }) - .ok(); - anyhow::Ok(()) - }) - .detach_and_notify_err(workspace_weak, window, cx); - } - - fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) { - let diff_base = self.diff_base(cx).clone(); - let DiffBase::Merge { base_ref } = diff_base else { - return; - }; - - let Some(repo) = self.branch_diff.read(cx).repo().cloned() else { - return; - }; - - let diff_receiver = repo.update(cx, |repo, cx| { - repo.diff( - DiffType::MergeBase { - base_ref: base_ref.clone(), - }, - cx, - ) - }); - - let workspace = self.workspace.clone(); - - window - .spawn(cx, { - let workspace = workspace.clone(); - async move |cx| { - let diff_text = diff_receiver.await??; - - if let Some(workspace) = workspace.upgrade() { - workspace.update_in(cx, |_workspace, window, cx| { - window.dispatch_action( - ReviewBranchDiff { - diff_text: diff_text.into(), - base_ref, - } - .boxed_clone(), - cx, - ); - })?; - } - - anyhow::Ok(()) - } - }) - .detach_and_notify_err(workspace, window, cx); - } - pub fn deploy_at( workspace: &mut Workspace, entry: Option, @@ -338,9 +114,7 @@ impl ProjectDiff { ); let intended_repo = workspace.project().read(cx).active_repository(cx); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).next(); let project_diff = if let Some(existing) = existing { existing.update(cx, |project_diff, cx| { project_diff.move_to_beginning(window, cx); @@ -365,15 +139,11 @@ impl ProjectDiff { if let Some(intended) = &intended_repo { let needs_switch = project_diff .read(cx) - .branch_diff - .read(cx) - .repo() + .repo(cx) .map_or(true, |current| current.read(cx).id != intended.read(cx).id); if needs_switch { project_diff.update(cx, |project_diff, cx| { - project_diff.branch_diff.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended.clone()), cx); - }); + project_diff.set_repo(Some(intended.clone()), cx); }); } } @@ -392,9 +162,7 @@ impl ProjectDiff { cx: &mut Context, ) { telemetry::event!("Git Diff Opened", source = "Agent Panel"); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).next(); let project_diff = if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); existing @@ -417,71 +185,7 @@ impl ProjectDiff { } pub fn autoscroll(&self, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.request_autoscroll(Autoscroll::fit(), cx); - }) - }) - } - - #[cfg(test)] - #[allow(dead_code)] - fn new_with_default_branch( - project: Entity, - workspace: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else { - return Task::ready(Err(anyhow!("No active repository"))); - }; - let main_branch = repo.update(cx, |repo, _| repo.default_branch(true)); - window.spawn(cx, async move |cx| { - let main_branch = main_branch - .await?? - .context("Could not determine default branch")?; - - let branch_diff = cx.new_window_entity(|window, cx| { - let mut branch_diff = branch_diff::BranchDiff::new( - DiffBase::Merge { - base_ref: main_branch, - }, - project.clone(), - window, - cx, - ); - branch_diff.set_repo(Some(repo.clone()), cx); - branch_diff - })?; - cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) - }) - }) - } - - fn new_with_branch_base( - project: Entity, - workspace: Entity, - base_ref: SharedString, - repo: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - window.spawn(cx, async move |cx| { - let branch_diff = cx.new_window_entity(|window, cx| { - let mut branch_diff = branch_diff::BranchDiff::new( - DiffBase::Merge { base_ref }, - project.clone(), - window, - cx, - ); - branch_diff.set_repo(Some(repo.clone()), cx); - branch_diff - })?; - cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) - }) - }) + self.diff.update(cx, |diff, cx| diff.autoscroll(cx)); } fn new( @@ -490,142 +194,69 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Self { - let branch_diff = - cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx)); + let branch_diff = cx.new(|cx| { + diff_buffer_list::DiffBufferList::new(DiffBase::Head, project.clone(), window, cx) + }); Self::new_impl(branch_diff, project, workspace, window, cx) } fn new_impl( - branch_diff: Entity, + branch_diff: Entity, project: Entity, workspace: Entity, window: &mut Window, cx: &mut Context, ) -> Self { - let focus_handle = cx.focus_handle(); - let multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); - multibuffer.set_all_diff_hunks_expanded(cx); - multibuffer - }); - - let editor = cx.new(|cx| { - let diff_display_editor = SplittableEditor::new( - EditorSettings::get_global(cx).diff_view_style, - multibuffer.clone(), + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No uncommitted changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, project.clone(), workspace.clone(), window, cx, - ); - match branch_diff.read(cx).diff_base() { - DiffBase::Head => {} - DiffBase::Merge { .. } => diff_display_editor.disable_diff_hunk_controls(cx), - } - diff_display_editor.rhs_editor().update(cx, |editor, cx| { - editor.set_show_diff_review_button(true, cx); - - match branch_diff.read(cx).diff_base() { - DiffBase::Head => { - editor.register_addon(GitPanelAddon { - workspace: workspace.downgrade(), - }); - } - DiffBase::Merge { .. } => { - editor.register_addon(BranchDiffAddon { - branch_diff: branch_diff.clone(), - }); - } - } - }); - diff_display_editor - }); - let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event); - - let primary_editor = editor.read(cx).rhs_editor().clone(); - let review_comment_subscription = - cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| { - if let EditorEvent::ReviewCommentsChanged { total_count } = event { - this.review_comment_count = *total_count; - cx.notify(); - } - }); - - let branch_diff_subscription = cx.subscribe_in( - &branch_diff, - window, - move |this, _git_store, event, window, cx| match event { - BranchDiffEvent::FileListChanged => { - this._task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - BranchDiffEvent::DiffBaseChanged => { - this.pending_scroll.take(); - this._task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - }, - ); - - let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; - let mut was_group_by = GitPanelSettings::get_global(cx).group_by; - let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; - let mut was_collapse_untracked_diff = - GitPanelSettings::get_global(cx).collapse_untracked_diff; - cx.observe_global_in::(window, move |this, window, cx| { - let settings = GitPanelSettings::get_global(cx); - let sort_by = settings.sort_by; - let group_by = settings.group_by; - let tree_view = settings.tree_view; - let is_collapse_untracked_diff = settings.collapse_untracked_diff; - if sort_by != was_sort_by - || group_by != was_group_by - || tree_view != was_tree_view - || is_collapse_untracked_diff != was_collapse_untracked_diff - { - this._task = { - window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - } - was_sort_by = sort_by; - was_group_by = group_by; - was_tree_view = tree_view; - was_collapse_untracked_diff = is_collapse_untracked_diff; - }) - .detach(); - - let task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + ) }); + Self::from_diff(diff, project, workspace, cx) + } + fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let observation = cx.observe(&diff, |_, _, cx| cx.notify()); Self { project, workspace: workspace.downgrade(), - branch_diff, - focus_handle, - editor, - multibuffer, - buffer_subscriptions: Default::default(), - pending_scroll: None, - review_comment_count: 0, - _task: task, - _subscription: Subscription::join( - branch_diff_subscription, - Subscription::join(editor_subscription, review_comment_subscription), - ), + diff, + _diff_observation: observation, } } pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { - self.branch_diff.read(cx).diff_base() + self.diff.read(cx).diff_base(cx) + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.diff.read(cx).repo(cx) + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.set_repo(repo.clone(), cx)); } pub fn move_to_entry( @@ -634,13 +265,8 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.branch_diff.read(cx).repo() else { - return; - }; - let repo = git_repo.read(cx); - let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx); - - self.move_to_path(path_key, window, cx) + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); } pub fn move_to_project_path( @@ -649,91 +275,42 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.branch_diff.read(cx).repo() else { - return; - }; - let Some(repo_path) = git_repo - .read(cx) - .project_path_to_repo_path(project_path, cx) - else { - return; - }; - let status = git_repo - .read(cx) - .status_for_path(&repo_path) - .map(|entry| entry.status) - .unwrap_or(FileStatus::Untracked); - let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx); - self.move_to_path(path_key, window, cx) - } - - fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); - }); - }); + self.diff.update(cx, |diff, cx| { + diff.move_to_project_path(project_path, window, cx) }); } - fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) { - if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::focused()), - window, - cx, - |s| { - s.select_ranges([position..position]); - }, - ) - }) - }); - } else { - self.pending_scroll = Some(path_key); - } + fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.move_to_beginning(window, cx)); } pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { - self.multibuffer.read(cx).snapshot(cx).total_changed_lines() + self.diff.read(cx).calculate_changed_lines(cx) } /// Returns the total count of review comments across all hunks/files. - pub fn total_review_comment_count(&self) -> usize { - self.review_comment_count + pub fn total_review_comment_count(&self, cx: &App) -> usize { + self.diff.read(cx).total_review_comment_count() } - /// Returns a reference to the splittable editor. - pub fn editor(&self) -> &Entity { - &self.editor + /// Returns the splittable editor of the currently-shown diff view. + pub fn editor(&self, cx: &App) -> Entity { + self.diff.read(cx).editor().clone() + } + + /// Returns the multibuffer of the currently-shown diff view. + pub fn multibuffer(&self, cx: &App) -> Entity { + self.diff.read(cx).multibuffer().clone() } fn button_states(&self, cx: &App) -> ButtonStates { - let editor = self.editor.read(cx).rhs_editor().read(cx); - let snapshot = self.multibuffer.read(cx).snapshot(cx); + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); let prev_next = snapshot.diff_hunks().nth(1).is_some(); - let mut selection = true; - - let mut ranges = editor - .selections - .disjoint_anchor_ranges() - .collect::>(); - if !ranges.iter().any(|range| range.start != range.end) { - selection = false; - let anchor = editor.selections.newest_anchor().head(); - if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) - && let Some(range) = snapshot - .anchor_in_buffer(excerpt_range.context.start) - .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) - .map(|(start, end)| start..end) - { - ranges = vec![range]; - } else { - ranges = Vec::default(); - }; - } + let (selection, ranges) = diff.selected_ranges(cx); let mut has_staged_hunks = false; let mut has_unstaged_hunks = false; for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) { @@ -774,488 +351,110 @@ impl ProjectDiff { } } - fn handle_editor_event( - &mut self, - editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::SelectionsChanged { local: true } => { - let Some(project_path) = self.active_project_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); - } - EditorEvent::Saved => { - self._task = - cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); - } - _ => {} - } - if editor.focus_handle(cx).contains_focused(window, cx) - && self.multibuffer.read(cx).is_empty() - { - self.focus_handle.focus(window, cx) - } + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_paths(&self, cx: &App) -> Vec> { + self.diff.read(cx).excerpt_paths(cx) } - #[instrument(skip_all)] - fn register_buffer( - &mut self, - path_key: PathKey, - file_status: FileStatus, - buffer: Entity, - diff: Entity, - conflict_set: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Option { - let diff_subscription = cx.subscribe_in(&diff, window, { - let path_key = path_key.clone(); - let buffer = buffer.clone(); - let diff = diff.clone(); - let conflict_set = conflict_set.clone(); - move |this, _, event, window, cx| match event { - buffer_diff::BufferDiffEvent::DiffChanged(_) => { - this.buffer_ranges_changed( - path_key.clone(), - file_status, - buffer.clone(), - diff.clone(), - conflict_set.clone(), - window, - cx, - ); - } - buffer_diff::BufferDiffEvent::BaseTextChanged - | buffer_diff::BufferDiffEvent::HunksStagedOrUnstaged(_) => {} - } - }); - let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, { - let path_key = path_key.clone(); - let buffer = buffer.clone(); - let diff = diff.clone(); - let conflict_set = conflict_set.clone(); - move |this, _, _, window, cx| { - this.buffer_ranges_changed( - path_key.clone(), - file_status, - buffer.clone(), - diff.clone(), - conflict_set.clone(), - window, - cx, - ) - } - }); - self.buffer_subscriptions.insert( - path_key.path.clone(), - BufferSubscriptions { - _diff: diff.clone(), - _diff_subscription: diff_subscription, - _conflict_set: conflict_set.clone(), - _conflict_set_subscription: conflict_set_subscription, - }, - ); + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_file_paths(&self, cx: &App) -> Vec { + self.diff.read(cx).excerpt_file_paths(cx) + } +} - let snapshot = buffer.read(cx).snapshot(); - let diff_snapshot = diff.read(cx).snapshot(cx); +/// Computes the canonical "uncommitted diffstat" for a repository: the sum of +/// `BufferDiff::changed_row_counts` over every buffer with uncommitted +/// changes (including untracked and deleted files). +/// +/// This is the same per-buffer quantity that the Uncommitted Diff view's +/// multibuffer sums in [`ProjectDiff::calculate_changed_lines`], so consumers +/// that need +added/-removed counts without opening the diff view (for +/// example, a per-project diffstat in a sidebar) always agree with the +/// numbers shown in the Uncommitted Diff tab's toolbar. +pub fn uncommitted_changed_lines( + project: &Entity, + repo: &Entity, + cx: &mut App, +) -> Task> { + let changed_paths: Vec = { + let repo = repo.read(cx); + repo.cached_status() + .filter(|entry| entry.status.has_changes()) + .filter_map(|entry| repo.repo_path_to_project_path(&entry.repo_path, cx)) + .collect() + }; + let diff_tasks: Vec<_> = changed_paths + .into_iter() + .map(|project_path| { + project.update(cx, |project, cx| { + let buffer_task = project.open_buffer(project_path, cx); + cx.spawn(async move |project, cx| { + let buffer = buffer_task.await?; + let diff = project + .update(cx, |project, cx| project.open_uncommitted_diff(buffer, cx))? + .await?; + anyhow::Ok(cx.update(|cx| diff.read(cx).changed_row_counts())) + }) + }) + }) + .collect(); + cx.spawn(async move |_| { + let mut added_rows = 0; + let mut removed_rows = 0; + for diff_task in diff_tasks { + let (added, removed) = diff_task.await?; + added_rows += added; + removed_rows += removed; + } + Ok((added_rows, removed_rows)) + }) +} - let excerpt_ranges = { - let diff_hunk_ranges = diff_snapshot - .hunks_intersecting_range( - Anchor::min_max_range_for_buffer(snapshot.remote_id()), - &snapshot, - ) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); - let conflicts = conflict_set.read(cx).snapshot(); - let mut conflicts = conflicts - .conflicts - .iter() - .map(|conflict| conflict.range.to_point(&snapshot)) - .peekable(); - - if conflicts.peek().is_some() { - conflicts.collect::>() - } else { - diff_hunk_ranges.collect() - } - }; +struct ButtonStates { + stage: bool, + unstage: bool, + prev_next: bool, + selection: bool, + stage_all: bool, + unstage_all: bool, +} - let buffer_id = snapshot.text.remote_id(); - let mut needs_fold = false; - - let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { - let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); - let is_newly_added = editor.update_excerpts_for_path( - path_key.clone(), - buffer, - excerpt_ranges, - multibuffer_context_lines(cx), - diff, - cx, - ); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffer_ranges_updated(editor, conflict_set, cx); - }); - (was_empty, is_newly_added) - }); +impl EventEmitter for ProjectDiff {} - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - if was_empty { - editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - selections.select_ranges([ - multi_buffer::Anchor::Min..multi_buffer::Anchor::Min - ]) - }, - ); - } - if is_excerpt_newly_added - && (file_status.is_deleted() - || (file_status.is_untracked() - && GitPanelSettings::get_global(cx).collapse_untracked_diff)) - { - needs_fold = true; - } - }) - }); +impl Focusable for ProjectDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} - if self.multibuffer.read(cx).is_empty() - && self - .editor - .read(cx) - .focus_handle(cx) - .contains_focused(window, cx) - { - self.focus_handle.focus(window, cx); - } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window, cx); - }); - } - if self.pending_scroll.as_ref() == Some(&path_key) { - self.move_to_path(path_key, window, cx); - } +impl Item for ProjectDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::DiffBoxed).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } - needs_fold.then_some(buffer_id) + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); } - fn buffer_ranges_changed( - &mut self, - path_key: PathKey, - file_status: FileStatus, - buffer: Entity, - diff: Entity, - conflict_set: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if buffer.read(cx).is_dirty() { - return; - } - self.register_buffer( - path_key, - file_status, - buffer, - diff, - conflict_set, - window, - cx, - ); - } - - #[instrument(skip(this, cx))] - pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { - let entries = this.update(cx, |this, cx| { - let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { - let load_buffers = branch_diff.load_buffers(cx); - (branch_diff.repo().cloned(), load_buffers) - }); - let mut previous_paths = this - .multibuffer - .read(cx) - .snapshot(cx) - .buffers_with_paths() - .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) - .collect::>(); - - let mut entries = BTreeMap::new(); - if let Some(repo) = repo { - let repo = repo.read(cx); - for diff_buffer in buffers_to_load { - let path_key = project_diff_path_key( - &repo, - &diff_buffer.repo_path, - diff_buffer.file_status, - cx, - ); - previous_paths.remove(&path_key); - entries.insert(path_key, diff_buffer); - } - } - - this.editor.update(cx, |editor, cx| { - for (path, buffer_id) in previous_paths { - this.buffer_subscriptions.remove(&path.path); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffers_removed(editor, &[buffer_id], cx); - }); - let _span = ztracing::info_span!("remove_excerpts_for_path"); - _span.enter(); - editor.remove_excerpts_for_path(path, cx); - } - }); - - entries - })?; - - let mut buffers_to_fold = Vec::new(); - - for (path_key, entry) in entries { - if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() { - // We might be lagging behind enough that all future entry.load futures are no longer pending. - // If that is the case, this task will never yield, starving the foreground thread of execution time. - yield_now().await; - cx.update(|window, cx| { - this.update(cx, |this, cx| { - if let Some(buffer_id) = this.register_buffer( - path_key, - entry.file_status, - buffer, - diff, - conflict_set, - window, - cx, - ) { - buffers_to_fold.push(buffer_id); - } - }) - .ok(); - })?; - } - } - this.update(cx, |this, cx| { - if !buffers_to_fold.is_empty() { - this.editor.update(cx, |editor, cx| { - editor - .rhs_editor() - .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); - }); - } - this.pending_scroll.take(); - cx.notify(); - })?; - - Ok(()) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn excerpt_paths(&self, cx: &App) -> Vec> { - let snapshot = self - .editor() - .read(cx) - .rhs_editor() - .read(cx) - .buffer() - .read(cx) - .snapshot(cx); - snapshot - .excerpts() - .map(|excerpt| { - snapshot - .path_for_buffer(excerpt.context.start.buffer_id) - .unwrap() - .path - .clone() - }) - .collect() - } - - /// Returns the real (worktree-relative) path of each excerpted buffer, in - /// the order the excerpts appear in the multibuffer. Unlike - /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather - /// than the (possibly synthetic) `PathKey` path used for sorting. - #[cfg(any(test, feature = "test-support"))] - pub fn excerpt_file_paths(&self, cx: &App) -> Vec { - let multibuffer = self - .editor() - .read(cx) - .rhs_editor() - .read(cx) - .buffer() - .clone(); - let snapshot = multibuffer.read(cx).snapshot(cx); - let mut result = Vec::new(); - let mut last_buffer_id = None; - for excerpt in snapshot.excerpts() { - let buffer_id = excerpt.context.start.buffer_id; - if last_buffer_id == Some(buffer_id) { - continue; - } - last_buffer_id = Some(buffer_id); - if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id) - && let Some(file) = buffer.read(cx).file() - { - result.push(file.path().as_unix_str().to_string()); - } - } - result - } -} - -const CONFLICT_SORT_PREFIX: u64 = 1; -const TRACKED_SORT_PREFIX: u64 = 2; -const NEW_SORT_PREFIX: u64 = 3; - -/// Computes a stable [`PathKey`] for a buffer in the project diff. -/// -/// The key is an intrinsic function of the file's own repo path and status; it -/// never depends on which other buffers happen to be present in the -/// multibuffer. This is required because the multibuffer uses the path key both -/// to order excerpts and to identify which excerpts belong to a given buffer, so -/// a key that shifted as files were added or removed would break that identity. -/// -/// Status grouping is encoded in the `sort_prefix`, and the within-group order -/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural -/// ordering reproduces the git panel's order. The path here is only ever used -/// for sorting and multibuffer identity; the path shown in the UI comes from the -/// buffer's own `File`. -fn project_diff_path_key( - repo: &Repository, - repo_path: &RepoPath, - status: FileStatus, - cx: &App, -) -> PathKey { - let settings = GitPanelSettings::get_global(cx); - let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { - TRACKED_SORT_PREFIX - } else if repo.had_conflict_on_last_merge_head_change(repo_path) { - CONFLICT_SORT_PREFIX - } else if status.is_created() { - NEW_SORT_PREFIX - } else { - TRACKED_SORT_PREFIX - }; - let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); - PathKey::with_sort_prefix(sort_prefix, path) -} - -fn project_diff_sort_path( - repo_path: &RelPath, - tree_view: bool, - sort_by: GitPanelSortBy, -) -> Arc { - if tree_view { - tree_sort_path(repo_path) - } else { - match sort_by { - GitPanelSortBy::Path => repo_path.into_arc(), - GitPanelSortBy::Name => name_sort_path(repo_path), - } - } -} - -/// Builds a synthetic path that sorts by file name first, falling back to the -/// full path to keep the key unique per file. -fn name_sort_path(repo_path: &RelPath) -> Arc { - let Some(file_name) = repo_path.file_name() else { - return repo_path.into_arc(); - }; - let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); - RelPath::unix(&synthetic) - .map(|path| path.into_arc()) - .unwrap_or_else(|_| repo_path.into_arc()) -} - -/// Builds a synthetic path whose natural component-wise ordering reproduces a -/// folder-first tree order. Each directory component is prefixed with a NUL -/// byte, which can never appear in a real path component and sorts before every -/// printable character, so at each level directories sort before files. -fn tree_sort_path(repo_path: &RelPath) -> Arc { - let components: Vec<&str> = repo_path.components().collect(); - if components.len() <= 1 { - return repo_path.into_arc(); - } - let last = components.len() - 1; - let mut synthetic = String::new(); - for (index, component) in components.into_iter().enumerate() { - if index > 0 { - synthetic.push('/'); - } - if index < last { - synthetic.push('\0'); - } - synthetic.push_str(component); - } - RelPath::unix(&synthetic) - .map(|path| path.into_arc()) - .unwrap_or_else(|_| repo_path.into_arc()) -} - -impl EventEmitter for ProjectDiff {} - -impl Focusable for ProjectDiff { - fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.multibuffer.read(cx).is_empty() { - self.focus_handle.clone() - } else { - self.editor.focus_handle(cx) - } - } -} - -impl Item for ProjectDiff { - type Event = EditorEvent; - - fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::DiffBoxed).color(Color::Muted)) - } - - fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { - Editor::to_item_events(event, f) - } - - fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.deactivated(window, cx); - }) - }); - } - - fn navigate( + fn navigate( &mut self, data: Arc, window: &mut Window, cx: &mut Context, ) -> bool { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.navigate(data, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) } fn tab_tooltip_text(&self, cx: &App) -> Option { - match self.diff_base(cx) { - DiffBase::Head => Some("Project Diff".into()), - DiffBase::Merge { .. } => Some("Branch Diff".into()), - } + Some(self.tab_content_text(0, cx)) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { @@ -1268,19 +467,16 @@ impl Item for ProjectDiff { .into_any_element() } - fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { - match self.branch_diff.read(cx).diff_base() { - DiffBase::Head => "Uncommitted Diff".into(), - DiffBase::Merge { base_ref } => format!("Diff since {}", base_ref).into(), - } + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Uncommitted Diff".into() } fn telemetry_event_text(&self) -> Option<&'static str> { Some("Project Diff Opened") } - fn as_searchable(&self, _: &Entity, _cx: &App) -> Option> { - Some(Box::new(self.editor.clone())) + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) } fn for_each_project_item( @@ -1288,26 +484,11 @@ impl Item for ProjectDiff { cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { - self.editor - .read(cx) - .rhs_editor() - .read(cx) - .for_each_project_item(cx, f) + self.diff.read(cx).for_each_project_item(cx, f) } fn active_project_path(&self, cx: &App) -> Option { - let editor = self.editor.read(cx).focused_editor().read(cx); - let multibuffer = editor.buffer().read(cx); - let position = editor.selections.newest_anchor().head(); - let snapshot = multibuffer.snapshot(cx); - let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; - let buffer = multibuffer.buffer(text_anchor.buffer_id)?; - - let file = buffer.read(cx).file()?; - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) + self.diff.read(cx).active_project_path(cx) } fn set_nav_history( @@ -1316,11 +497,8 @@ impl Item for ProjectDiff { _: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, _| { - primary_editor.set_nav_history(Some(nav_history)); - }) - }); + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); } fn can_split(&self) -> bool { @@ -1345,11 +523,11 @@ impl Item for ProjectDiff { } fn is_dirty(&self, cx: &App) -> bool { - self.multibuffer.read(cx).is_dirty(cx) + self.diff.read(cx).is_dirty(cx) } fn has_conflict(&self, cx: &App) -> bool { - self.multibuffer.read(cx).has_conflict(cx) + self.diff.read(cx).has_conflict(cx) } fn can_save(&self, _: &App) -> bool { @@ -1363,11 +541,8 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.save(options, project, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) } fn save_as( @@ -1386,11 +561,8 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.reload(project, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) } fn act_as_type<'a>( @@ -1402,9 +574,19 @@ impl Item for ProjectDiff { if type_id == TypeId::of::() { Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.read(cx).rhs_editor().clone().into()) + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) } else if type_id == TypeId::of::() { - Some(self.editor.clone().into()) + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) } else { None } @@ -1416,88 +598,15 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.added_to_workspace(workspace, window, cx) + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) }); } } impl Render for ProjectDiff { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_empty = self.multibuffer.read(cx).is_empty(); - let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready(); - - let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. }); - - div() - .track_focus(&self.focus_handle) - .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) - .when(is_branch_diff_view, |this| { - this.on_action(cx.listener(Self::review_diff)) - }) - .bg(cx.theme().colors().editor_background) - .flex() - .items_center() - .justify_center() - .size_full() - .when(is_empty && is_loading, |el| { - let rems = TextSize::Large.rems(cx); - el.child( - Icon::new(IconName::LoadCircle) - .size(IconSize::Custom(rems)) - .color(Color::Accent) - .with_rotate_animation(3) - .into_any_element(), - ) - }) - .when(is_empty && !is_loading, |el| { - let remote_button = if let Some(panel) = self - .workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).panel::(cx)) - { - panel.update(cx, |panel, cx| panel.render_remote_button(cx)) - } else { - None - }; - let keybinding_focus_handle = self.focus_handle(cx); - el.child( - v_flex() - .gap_1() - .child( - h_flex() - .justify_around() - .child(Label::new("No uncommitted changes")), - ) - .map(|el| match remote_button { - Some(button) => el.child(h_flex().justify_around().child(button)), - None => el.child( - h_flex() - .justify_around() - .child(Label::new("Remote up to date")), - ), - }) - .child( - h_flex().justify_around().mt_1().child( - Button::new("project-diff-close-button", "Close") - // .style(ButtonStyle::Transparent) - .key_binding(KeyBinding::for_action_in( - &CloseActiveItem::default(), - &keybinding_focus_handle, - cx, - )) - .on_click(move |_, window, cx| { - window.focus(&keybinding_focus_handle, cx); - window.dispatch_action( - Box::new(CloseActiveItem::default()), - cx, - ); - }), - ), - ), - ) - }) - .when(!is_empty, |el| el.child(self.editor.clone())) + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.diff.clone()) } } @@ -1518,46 +627,38 @@ impl SerializableItem for ProjectDiff { fn deserialize( project: Entity, workspace: WeakEntity, - workspace_id: workspace::WorkspaceId, - item_id: workspace::ItemId, + _workspace_id: workspace::WorkspaceId, + _item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { - let db = persistence::ProjectDiffDb::global(cx); window.spawn(cx, async move |cx| { - let diff_base = db.get_diff_base(item_id, workspace_id)?; - - let diff = cx.update(|window, cx| { - let branch_diff = cx - .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx)); + cx.update(|window, cx| { + let branch_diff = cx.new(|cx| { + diff_buffer_list::DiffBufferList::new( + DiffBase::Head, + project.clone(), + window, + cx, + ) + }); let workspace = workspace.upgrade().context("workspace gone")?; anyhow::Ok( cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)), ) - })??; - - Ok(diff) + })? }) } fn serialize( &mut self, - workspace: &mut Workspace, - item_id: workspace::ItemId, - _closing: bool, - _window: &mut Window, - cx: &mut Context, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, ) -> Option>> { - let workspace_id = workspace.database_id()?; - let diff_base = self.diff_base(cx).clone(); - - let db = persistence::ProjectDiffDb::global(cx); - Some(cx.background_spawn({ - async move { - db.save_diff_base(item_id, workspace_id, diff_base.clone()) - .await - } - })) + Some(Task::ready(Ok(()))) } fn should_serialize(&self, _: &Self::Event) -> bool { @@ -1565,14 +666,14 @@ impl SerializableItem for ProjectDiff { } } -mod persistence { +pub(crate) mod persistence { use anyhow::Context as _; use db::{ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, sqlez_macros::sql, }; - use project::git_store::branch_diff::DiffBase; + use project::git_store::diff_buffer_list::DiffBase; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; pub struct ProjectDiffDb(ThreadSafeConnection); @@ -1580,7 +681,11 @@ mod persistence { impl Domain for ProjectDiffDb { const NAME: &str = stringify!(ProjectDiffDb); - const MIGRATIONS: &[&str] = &[sql!( + // Legacy databases stored branch diffs under the "ProjectDiff" item + // kind, disambiguated by the `diff_base` column. Step 1 rewrites those + // item kinds so that each diff view owns its serialized kind. + const MIGRATIONS: &[&str] = &[ + sql!( CREATE TABLE project_diffs( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -1591,13 +696,23 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + ), + r#" + UPDATE items SET kind = 'BranchDiff' + WHERE kind = 'ProjectDiff' AND EXISTS ( + SELECT 1 FROM project_diffs + WHERE project_diffs.item_id = items.item_id + AND project_diffs.workspace_id = items.workspace_id + AND project_diffs.diff_base LIKE '{"Merge"%' + ); + "#, + ]; } db::static_connection!(ProjectDiffDb, [WorkspaceDb]); impl ProjectDiffDb { - pub async fn save_diff_base( + pub async fn save_project_diff_base( &self, item_id: ItemId, workspace_id: WorkspaceId, @@ -1617,7 +732,7 @@ mod persistence { .await } - pub fn get_diff_base( + pub fn get_project_diff_base( &self, item_id: ItemId, workspace_id: WorkspaceId, @@ -1703,7 +818,6 @@ impl ToolbarItemView for ProjectDiffToolbar { ) -> ToolbarItemLocation { self.project_diff = active_pane_item .and_then(|item| item.act_as::(cx)) - .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head) .map(|entity| entity.downgrade()); if self.project_diff.is_some() { ToolbarItemLocation::PrimaryRight @@ -1721,15 +835,6 @@ impl ToolbarItemView for ProjectDiffToolbar { } } -struct ButtonStates { - stage: bool, - unstage: bool, - prev_next: bool, - selection: bool, - stage_all: bool, - unstage_all: bool, -} - impl Render for ProjectDiffToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(project_diff) = self.project_diff(cx) else { @@ -1737,18 +842,63 @@ impl Render for ProjectDiffToolbar { }; let focus_handle = project_diff.focus_handle(cx); let button_states = project_diff.read(cx).button_states(cx); - let review_count = project_diff.read(cx).total_review_comment_count(); + let review_count = project_diff.read(cx).total_review_comment_count(cx); + + let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); + let is_multibuffer_empty = project_diff.read(cx).multibuffer(cx).read(cx).is_empty(); - h_group_xl() + let stage_all_button_width = rems(5.); + + h_flex() .my_neg_1() .py_1() - .items_center() + .gap_1p5() .flex_wrap() .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "project-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) .child( h_group_sm() - .when(button_states.selection, |el| { - el.child( + .when(button_states.selection, |this| { + this.child( Button::new("stage", "Toggle Staged") .tooltip(Tooltip::for_action_title_in( "Toggle Staged", @@ -1761,127 +911,83 @@ impl Render for ProjectDiffToolbar { })), ) }) - .when(!button_states.selection, |el| { - el.child( + .when(!button_states.selection, |this| { + this.child( Button::new("stage", "Stage") + .disabled(!button_states.stage) .tooltip(Tooltip::for_action_title_in( - "Stage and go to next hunk", + "Stage and Go to Next Hunk", &StageAndNext, &focus_handle, )) - .disabled( - !button_states.prev_next - && !button_states.stage_all - && !button_states.unstage_all, - ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&StageAndNext, window, cx) })), ) .child( Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) .tooltip(Tooltip::for_action_title_in( - "Unstage and go to next hunk", + "Unstage and Go to Next Hunk", &UnstageAndNext, &focus_handle, )) - .disabled( - !button_states.prev_next - && !button_states.stage_all - && !button_states.unstage_all, - ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&UnstageAndNext, window, cx) })), ) }), ) - // n.b. the only reason these arrows are here is because we don't - // support "undo" for staging so we need a way to go back. - .child( - h_group_sm() - .child( - IconButton::new("up", IconName::ArrowUp) - .shape(ui::IconButtonShape::Square) + .child(Divider::vertical()) + .when( + button_states.unstage_all && !button_states.stage_all, + |this| { + this.child( + Button::new("unstage-all", "Unstage All") + .width(stage_all_button_width) .tooltip(Tooltip::for_action_title_in( - "Go to previous hunk", - &GoToPreviousHunk, + "Unstage All Changes", + &UnstageAll, &focus_handle, )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToPreviousHunk, window, cx) - })), + .on_click( + cx.listener(|this, _, window, cx| this.unstage_all(window, cx)), + ), ) - .child( - IconButton::new("down", IconName::ArrowDown) - .shape(ui::IconButtonShape::Square) + }, + ) + .when( + !button_states.unstage_all || button_states.stage_all, + |this| { + this.child( + Button::new("stage-all", "Stage All") + .width(stage_all_button_width) + .disabled(!button_states.stage_all) .tooltip(Tooltip::for_action_title_in( - "Go to next hunk", - &GoToHunk, + "Stage All Changes", + &StageAll, &focus_handle, )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToHunk, window, cx) - })), - ), + .on_click( + cx.listener(|this, _, window, cx| this.stage_all(window, cx)), + ), + ) + }, ) - .child(vertical_divider()) + .child(Divider::vertical()) .child( - h_group_sm() - .when( - button_states.unstage_all && !button_states.stage_all, - |el| { - el.child( - Button::new("unstage-all", "Unstage All") - .tooltip(Tooltip::for_action_title_in( - "Unstage all changes", - &UnstageAll, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.unstage_all(window, cx) - })), - ) - }, - ) - .when( - !button_states.unstage_all || button_states.stage_all, - |el| { - el.child( - // todo make it so that changing to say "Unstaged" - // doesn't change the position. - div().child( - Button::new("stage-all", "Stage All") - .disabled(!button_states.stage_all) - .tooltip(Tooltip::for_action_title_in( - "Stage all changes", - &StageAll, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.stage_all(window, cx) - })), - ), - ) - }, - ) - .child( - Button::new("commit", "Commit") - .tooltip(Tooltip::for_action_title_in( - "Commit", - &Commit, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&Commit, window, cx); - })), - ), + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), ) - // "Send Review to Agent" button (only shown when there are review comments) .when(review_count > 0, |el| { - el.child(vertical_divider()).child( + el.child(Divider::vertical()).child( render_send_review_to_agent_button(review_count, &focus_handle).on_click( cx.listener(|this, _, window, cx| { this.dispatch_action(&SendReviewToAgent, window, cx) @@ -1892,7 +998,10 @@ impl Render for ProjectDiffToolbar { } } -fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button { +pub(crate) fn render_send_review_to_agent_button( + review_count: usize, + focus_handle: &FocusHandle, +) -> Button { Button::new( "send-review", format!("Send Review to Agent ({})", review_count), @@ -1909,236 +1018,107 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH )) } -pub struct BranchDiffToolbar { - project_diff: Option>, -} +#[cfg(test)] +mod tests { + use buffer_diff::DiffHunkSecondaryStatus; + use db::indoc; + use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; + use gpui::TestAppContext; + use multi_buffer::PathKey; + use project::FakeFs; + use serde_json::json; + use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore}; + use std::path::Path; + use unindent::Unindent as _; + use util::{path, rel_path::rel_path}; -impl BranchDiffToolbar { - pub fn new(_cx: &mut Context) -> Self { - Self { project_diff: None } - } + use workspace::MultiWorkspace; - fn project_diff(&self, _: &App) -> Option> { - self.project_diff.as_ref()?.upgrade() + use super::*; + + #[ctor::ctor(unsafe)] + fn init_logger() { + zlog::init_test(); } - fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { - if let Some(project_diff) = self.project_diff(cx) { - project_diff.focus_handle(cx).focus(window, cx); - } - let action = action.boxed_clone(); - cx.defer(move |cx| { - cx.dispatch_action(action.as_ref()); - }) + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Unified); + }); + }); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); } -} -impl EventEmitter for BranchDiffToolbar {} + use zed_actions::git as git_actions; -impl ToolbarItemView for BranchDiffToolbar { - fn set_active_pane_item( - &mut self, - active_pane_item: Option<&dyn ItemHandle>, - _: &mut Window, - cx: &mut Context, - ) -> ToolbarItemLocation { - self.project_diff = active_pane_item - .and_then(|item| item.act_as::(cx)) - .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })) - .map(|entity| entity.downgrade()); - if self.project_diff.is_some() { - ToolbarItemLocation::PrimaryRight - } else { - ToolbarItemLocation::Hidden - } - } - - fn pane_focus_update( - &mut self, - _pane_focused: bool, - _window: &mut Window, - _cx: &mut Context, - ) { - } -} + use crate::project_diff::{self, ProjectDiff}; -impl Render for BranchDiffToolbar { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(project_diff) = self.project_diff(cx) else { - return div(); + #[test] + fn test_legacy_branch_diff_rows_migrate_to_their_own_kind() { + use db::sqlez::{ + connection::Connection, + domain::{Domain as _, Migrator as _}, }; - let focus_handle = project_diff.focus_handle(cx); - let review_count = project_diff.read(cx).total_review_comment_count(); - let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); - let diff_base = project_diff.read(cx).diff_base(cx).clone(); - let DiffBase::Merge { base_ref } = diff_base else { - return div(); - }; - let selected_base_ref = base_ref.clone(); - let base_ref_label = format!("Base: {base_ref}"); - let repository = project_diff.read(cx).branch_diff.read(cx).repo().cloned(); - let workspace = project_diff.read(cx).workspace.clone(); - let project_diff_for_picker = project_diff.downgrade(); - let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty(); - let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); - - let show_review_button = !is_multibuffer_empty && is_ai_enabled; - - h_group_xl() - .my_neg_1() - .py_1() - .items_center() - .flex_wrap() - .justify_end() - .gap_2() - .child( - PopoverMenu::new("branch-diff-base-branch-picker") - .menu(move |window, cx| { - let project_diff = project_diff_for_picker.clone(); - let on_select = Arc::new( - move |branch: git::repository::Branch, - _window: &mut Window, - cx: &mut App| { - let base_ref: SharedString = branch.name().to_owned().into(); - project_diff - .update(cx, |project_diff, cx| { - let branch_diff = &mut project_diff.branch_diff; - branch_diff.update(cx, |branch_diff, cx| { - branch_diff - .set_diff_base(DiffBase::Merge { base_ref }, cx); - }); - cx.notify(); - }) - .ok(); - }, - ); - Some(branch_picker::select_popover( - workspace.clone(), - repository.clone(), - Some(selected_base_ref.clone()), - on_select, - window, - cx, - )) - }) - .trigger_with_tooltip( - Button::new("branch-diff-base-branch", base_ref_label) - .color(Color::Muted) - .end_icon( - Icon::new(IconName::ChevronDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ), - Tooltip::text("Select base branch"), - ), + let connection = Connection::open_memory(Some( + "test_legacy_branch_diff_rows_migrate_to_their_own_kind", + )); + connection.exec("PRAGMA foreign_keys = OFF").unwrap()().unwrap(); + workspace::WorkspaceDb::migrate(&connection).unwrap(); + connection + .migrate( + persistence::ProjectDiffDb::NAME, + &persistence::ProjectDiffDb::MIGRATIONS[..1], + &mut |_, _, _| false, ) - .when(!is_multibuffer_empty, |this| { - this.child(DiffStat::new( - "branch-diff-stat", - additions as usize, - deletions as usize, - )) - }) - .when(show_review_button, |this| { - let focus_handle = focus_handle.clone(); - this.child(Divider::vertical()).child( - Button::new("review-diff", "Review Diff") - .start_icon( - Icon::new(IconName::ZedAssistant) - .size(IconSize::Small) - .color(Color::Muted), - ) - .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx)) - .tooltip(move |_, cx| { - Tooltip::with_meta_in( - "Review Diff", - Some(&ReviewDiff), - "Send this diff for your last agent to review.", - &focus_handle, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&ReviewDiff, window, cx); - })), - ) - }) - .when(review_count > 0, |this| { - this.child(vertical_divider()).child( - render_send_review_to_agent_button(review_count, &focus_handle).on_click( - cx.listener(|this, _, window, cx| { - this.dispatch_action(&SendReviewToAgent, window, cx) - }), - ), - ) - }) - } -} - -struct BranchDiffAddon { - branch_diff: Entity, -} - -impl Addon for BranchDiffAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn override_status_for_buffer_id( - &self, - buffer_id: language::BufferId, - cx: &App, - ) -> Option { - self.branch_diff - .read(cx) - .status_for_buffer_id(buffer_id, cx) - } -} - -#[cfg(test)] -mod tests { - use collections::HashMap; - use db::indoc; - use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; - use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode}; - use gpui::TestAppContext; - use project::FakeFs; - use serde_json::json; - use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore}; - use std::path::Path; - use unindent::Unindent as _; - use util::{ - path, - rel_path::{RelPath, rel_path}, - }; - - use workspace::MultiWorkspace; + .unwrap(); - use super::*; + connection + .exec( + "INSERT INTO workspaces(workspace_id) VALUES (1); + INSERT INTO panes(pane_id, workspace_id, active) VALUES (1, 1, 1); + INSERT INTO items(item_id, workspace_id, pane_id, kind, position, active) VALUES + (1, 1, 1, 'ProjectDiff', 0, 1), + (2, 1, 1, 'ProjectDiff', 1, 0)", + ) + .unwrap()() + .unwrap(); + let head = serde_json::to_string(&DiffBase::Head).unwrap(); + let merge = serde_json::to_string(&DiffBase::Merge { + base_ref: "main".into(), + }) + .unwrap(); + connection + .exec_bound::<(String, String)>( + "INSERT INTO project_diffs(workspace_id, item_id, diff_base) VALUES (1, 1, ?), (1, 2, ?)", + ) + .unwrap()((head, merge)) + .unwrap(); - #[ctor::ctor(unsafe)] - fn init_logger() { - zlog::init_test(); - } + persistence::ProjectDiffDb::migrate(&connection).unwrap(); - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let store = SettingsStore::test(cx); - cx.set_global(store); - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - settings.editor.diff_view_style = Some(DiffViewStyle::Unified); - }); - }); - theme_settings::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - crate::init(cx); - }); + let kinds = connection + .select::<(i64, String)>("SELECT item_id, kind FROM items ORDER BY item_id") + .unwrap()() + .unwrap(); + assert_eq!( + kinds, + [ + (1, "ProjectDiff".to_string()), + (2, "BranchDiff".to_string()) + ] + ); } #[gpui::test] - async fn test_save_after_restore(cx: &mut TestAppContext) { + async fn test_update_on_uncommit(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); @@ -2146,58 +1126,59 @@ mod tests { path!("/project"), json!({ ".git": {}, - "foo.txt": "FOO\n", + "README.md": "# My cool project\n".to_owned() }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - fs.set_head_for_repo( - path!("/project/.git").as_ref(), - &[("foo.txt", "foo\n".into())], - "deadbeef", - ); - fs.set_index_for_repo( - path!("/project/.git").as_ref(), - &[("foo.txt", "foo\n".into())], + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("README.md", "# My cool project\n".to_owned())], ); - + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let diff = cx.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) - }); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); - assert_state_with_diff( - &editor, - cx, - &" - - ˇfoo - + FOO - " - .unindent(), - ); - - editor - .update_in(cx, |editor, window, cx| { - editor.git_restore(&Default::default(), window, cx); - editor.save(SaveOptions::default(), project.clone(), window, cx) + let _editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) }) .await + .unwrap() + .downcast::() .unwrap(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + cx.focus(&item); + let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone()); + + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[( + "README.md", + "# My cool project\nDetails to come.\n".to_owned(), + )], + ); cx.run_until_parked(); - assert_state_with_diff(&editor, cx, &"ˇ".unindent()); + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap(); - assert_eq!(text, "foo\n"); + cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); } #[gpui::test] - async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) { + async fn test_uncommitted_diff_tab_reflects_working_tree_edits(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); @@ -2205,186 +1186,434 @@ mod tests { path!("/project"), json!({ ".git": {}, - "bar": "BAR\n", - "foo": "FOO\n", + "file.txt": "one\nTWO\nthree\n", }), ) .await; + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("file.txt", "one\ntwo\nthree\n".to_owned())], + ); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let diff = cx.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) - }); cx.run_until_parked(); - fs.set_head_and_index_for_repo( - path!("/project/.git").as_ref(), - &[("bar", "bar\n".into()), ("foo", "foo\n".into())], - ); + // Open a regular editor tab for the active file, then deploy the + // Uncommitted Diff tab. Both items must coexist in the workspace. + let _editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("file.txt")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); cx.run_until_parked(); - let editor = cx.update_window_entity(&diff, |diff, window, cx| { - diff.move_to_path( - PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), - window, - cx, + let diff_item = workspace.update(cx, |workspace, cx| { + let diff_item = workspace.active_item_as::(cx).unwrap(); + assert!( + workspace.items_of_type::(cx).next().is_some(), + "the editor tab should still be open alongside the diff tab" ); - diff.editor.read(cx).rhs_editor().clone() + diff_item + }); + assert_eq!( + diff_item.read_with(cx, |diff_item, cx| diff_item.tab_content_text(0, cx)), + "Uncommitted Diff" + ); + assert_eq!( + diff_item.read_with(cx, |diff_item, cx| diff_item.excerpt_file_paths(cx)), + vec!["file.txt"] + ); + + let diff_editor = diff_item.read_with(cx, |diff_item, cx| { + diff_item.editor(cx).read(cx).rhs_editor().clone() }); assert_state_with_diff( - &editor, + &diff_editor, cx, &" - - bar - + BAR - - - ˇfoo - + FOO + ˇone + - two + + TWO + three " .unindent(), ); + assert_eq!( + diff_item.read_with(cx, |diff_item, cx| diff_item.calculate_changed_lines(cx)), + (1, 1) + ); - let editor = cx.update_window_entity(&diff, |diff, window, cx| { - diff.move_to_path( - PathKey::with_sort_prefix(2, rel_path("bar").into_arc()), - window, - cx, - ); - diff.editor.read(cx).rhs_editor().clone() + // Edit the working tree; the resulting status change must refresh the + // already-open diff view. + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.txt"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + let end = buffer.len(); + buffer.edit([(end..end, "four\n")], None, cx); }); + project + .update(cx, |project, cx| project.save_buffer(buffer, cx)) + .await + .unwrap(); + cx.run_until_parked(); + assert_state_with_diff( - &editor, + &diff_editor, cx, &" - - ˇbar - + BAR - - - foo - + FOO + ˇone + - two + + TWO + three + + four " .unindent(), ); + assert_eq!( + diff_item.read_with(cx, |diff_item, cx| diff_item.calculate_changed_lines(cx)), + (2, 1) + ); } #[gpui::test] - async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) { + async fn test_calculate_changed_lines_matches_uncommitted_diffstat(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); + // a.txt: one line changed, two lines added (+3/-1). + // b.txt: untracked, two lines added (+2/-0). + // c.txt: deleted, three lines removed (+0/-3). fs.insert_tree( path!("/project"), json!({ ".git": {}, - "foo": "modified\n", + "a.txt": "one\nTWO\nthree\nfour\nfive\n", + "b.txt": "x\ny\n", }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[ + ("a.txt", "one\ntwo\nthree\n".to_owned()), + ("c.txt", "p\nq\nr\n".to_owned()), + ], + ); + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - fs.set_head_for_repo( - path!("/project/.git").as_ref(), - &[("foo", "original\n".into())], - "deadbeef", - ); + cx.run_until_parked(); - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/project/foo"), cx) - }) + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let view_counts = + diff_item.read_with(cx, |diff_item, cx| diff_item.calculate_changed_lines(cx)); + assert_eq!(view_counts, (5, 4)); + + // The canonical diffstat helper must agree with the diff view. + let repo = project + .read_with(cx, |project, cx| project.active_repository(cx)) + .unwrap(); + let canonical_counts = cx + .update(|_window, cx| uncommitted_changed_lines(&project, &repo, cx)) .await .unwrap(); - let buffer_editor = cx.new_window_entity(|window, cx| { - Editor::for_buffer(buffer, Some(project.clone()), window, cx) + assert_eq!(canonical_counts, view_counts); + } + + #[gpui::test] + async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project_a"), + json!({ + ".git": {}, + "a.txt": "CHANGED_A\n", + }), + ) + .await; + fs.insert_tree( + path!("/project_b"), + json!({ + ".git": {}, + "b.txt": "CHANGED_B\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + Path::new(path!("/project_a/.git")), + &[("a.txt", "original_a\n".to_string())], + ); + fs.set_head_and_index_for_repo( + Path::new(path!("/project_b/.git")), + &[("b.txt", "original_b\n".to_string())], + ); + + let project = Project::test( + fs.clone(), + [ + Path::new(path!("/project_a")), + Path::new(path!("/project_b")), + ], + cx, + ) + .await; + + let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| { + let mut worktrees: Vec<_> = project.worktrees(cx).collect(); + worktrees.sort_by_key(|w| w.read(cx).abs_path()); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) }); - let diff = cx.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + // Select project A explicitly and open the diff. + workspace.update(cx, |workspace, cx| { + let git_store = workspace.project().read(cx).git_store().clone(); + git_store.update(cx, |git_store, cx| { + git_store.set_active_repo_for_worktree(worktree_a_id, cx); + }); + }); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); }); cx.run_until_parked(); - let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + assert_eq!(paths_a.len(), 1); + assert_eq!(*paths_a[0], *"a.txt"); - assert_state_with_diff( - &diff_editor, - cx, - &" - - ˇoriginal - + modified - " - .unindent(), + // Switch the explicit active repository to project B and re-run the diff action. + workspace.update(cx, |workspace, cx| { + let git_store = workspace.project().read(cx).git_store().clone(); + git_store.update(cx, |git_store, cx| { + git_store.set_active_repo_for_worktree(worktree_b_id, cx); + }); + }); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let same_diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_eq!(diff_item.entity_id(), same_diff_item.entity_id()); + + let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + assert_eq!(paths_b.len(), 1); + assert_eq!(*paths_b[0], *"b.txt"); + } + + #[gpui::test] + async fn test_project_diff_actions_filter_mixed_staged_and_unstaged_hunks( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], ); - let prev_buffer_hunks = - cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { - let snapshot = buffer_editor.snapshot(window, cx); - let snapshot = &snapshot.buffer_snapshot(); - let prev_buffer_hunks = buffer_editor - .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot) - .collect::>(); - buffer_editor.git_restore(&Default::default(), window, cx); - prev_buffer_hunks - }); - assert_eq!(prev_buffer_hunks.len(), 1); + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.run_until_parked(); - let new_buffer_hunks = - cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { - let snapshot = buffer_editor.snapshot(window, cx); - let snapshot = &snapshot.buffer_snapshot(); - buffer_editor - .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot) - .collect::>() - }); - assert_eq!(new_buffer_hunks.as_slice(), &[]); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); - cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { - buffer_editor.set_text("different\n", window, cx); - buffer_editor.save( - SaveOptions { - format: false, - force_format: false, - autosave: false, - }, - project.clone(), - window, - cx, - ) - }) - .await - .unwrap(); + let diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let diff_editor = + diff_item.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + assert_eq!( + diff_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![ + DiffHunkSecondaryStatus::HasSecondaryHunk, + DiffHunkSecondaryStatus::NoSecondaryHunk, + ] + ); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewUnstagedChanges.boxed_clone(), cx); + }); cx.run_until_parked(); - cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { - buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx); + let unstaged_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() }); + assert_ne!(diff_item.entity_id(), unstaged_item.entity_id()); + let unstaged_editor = workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item(cx).unwrap(); + assert_eq!(active_item.tab_content_text(0, cx), "Unstaged Changes"); + active_item + .act_as::(cx) + .unwrap() + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + assert_eq!( + unstaged_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![DiffHunkSecondaryStatus::NoSecondaryHunk] + ); - assert_state_with_diff( - &buffer_editor, - cx, - &" - - original - + different - ˇ" - .unindent(), + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewUncommittedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let uncommitted_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_eq!(diff_item.entity_id(), uncommitted_item.entity_id()); + assert_eq!( + uncommitted_item.read_with(cx, |diff, cx| diff.tab_content_text(0, cx)), + "Uncommitted Diff" + ); + let uncommitted_editor = uncommitted_item + .read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + assert_eq!( + uncommitted_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![ + DiffHunkSecondaryStatus::HasSecondaryHunk, + DiffHunkSecondaryStatus::NoSecondaryHunk, + ] ); - assert_state_with_diff( - &diff_editor, - cx, - &" - - ˇoriginal - + different - " - .unindent(), + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewStagedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let staged_editor = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap(); + let active_item = workspace.active_item(cx).unwrap(); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + active_item + .act_as::(cx) + .unwrap() + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + assert_eq!( + staged_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![DiffHunkSecondaryStatus::NoSecondaryHunk] ); } - use crate::project_diff::{self, ProjectDiff}; - #[gpui::test] async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { init_test(cx); @@ -2428,7 +1657,7 @@ mod tests { workspace.active_item_as::(cx).unwrap() }); cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); + let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone()); let mut cx = EditorTestContext::for_editor_in(editor, cx).await; @@ -2473,242 +1702,254 @@ mod tests { } #[gpui::test] - async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) { + async fn test_save_after_restore(cx: &mut TestAppContext) { init_test(cx); - let git_contents = indoc! {r#" - #[rustfmt::skip] - fn main() { - let x = 0.0; // this line will be removed - // 1 - // 2 - // 3 - let y = 0.0; // this line will be removed - // 1 - // 2 - // 3 - let arr = [ - 0.0, // this line will be removed - 0.0, // this line will be removed - 0.0, // this line will be removed - 0.0, // this line will be removed - ]; - } - "#}; - let buffer_contents = indoc! {" - #[rustfmt::skip] - fn main() { - // 1 - // 2 - // 3 - // 1 - // 2 - // 3 - let arr = [ - ]; - } - "}; - let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/a"), + path!("/project"), json!({ ".git": {}, - "main.rs": buffer_contents, + "foo.txt": "FOO\n", }), ) .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - fs.set_head_and_index_for_repo( - Path::new(path!("/a/.git")), - &[("main.rs", git_contents.to_owned())], + fs.set_head_for_repo( + path!("/project/.git").as_ref(), + &[("foo.txt", "foo\n".into())], + "deadbeef", + ); + fs.set_index_for_repo( + path!("/project/.git").as_ref(), + &[("foo.txt", "foo\n".into())], ); - let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) + }); + cx.run_until_parked(); + + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + assert_state_with_diff( + &editor, + cx, + &" + - ˇfoo + + FOO + " + .unindent(), + ); + editor + .update_in(cx, |editor, window, cx| { + editor.git_restore(&Default::default(), window, cx); + editor.save(SaveOptions::default(), project.clone(), window, cx) + }) + .await + .unwrap(); cx.run_until_parked(); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + assert_state_with_diff(&editor, cx, &"ˇ".unindent()); + + let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap(); + assert_eq!(text, "foo\n"); + } + + #[gpui::test] + async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "bar": "BAR\n", + "foo": "FOO\n", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) }); + cx.run_until_parked(); + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("bar", "bar\n".into()), ("foo", "foo\n".into())], + ); cx.run_until_parked(); - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() + let editor = cx.update_window_entity(&diff, |diff, window, cx| { + diff.diff.update(cx, |diff, cx| { + diff.move_to_path( + PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), + window, + cx, + ) + }); + diff.editor(cx).read(cx).rhs_editor().clone() }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); - - let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + assert_state_with_diff( + &editor, + cx, + &" + - bar + + BAR - cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); + - ˇfoo + + FOO + " + .unindent(), + ); - cx.dispatch_action(editor::actions::GoToHunk); - cx.dispatch_action(editor::actions::GoToHunk); - cx.dispatch_action(git::Restore); - cx.dispatch_action(editor::actions::MoveToBeginning); + let editor = cx.update_window_entity(&diff, |diff, window, cx| { + diff.diff.update(cx, |diff, cx| { + diff.move_to_path( + PathKey::with_sort_prefix(2, rel_path("bar").into_arc()), + window, + cx, + ) + }); + diff.editor(cx).read(cx).rhs_editor().clone() + }); + assert_state_with_diff( + &editor, + cx, + &" + - ˇbar + + BAR - cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); + - foo + + FOO + " + .unindent(), + ); } - #[gpui::test(iterations = 50)] - async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics( - cx: &mut TestAppContext, - ) { + #[gpui::test] + async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - settings.editor.diff_view_style = Some(DiffViewStyle::Split); - }); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "foo": "modified\n", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + fs.set_head_for_repo( + path!("/project/.git").as_ref(), + &[("foo", "original\n".into())], + "deadbeef", + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/foo"), cx) + }) + .await + .unwrap(); + let buffer_editor = cx.new_window_entity(|window, cx| { + Editor::for_buffer(buffer, Some(project.clone()), window, cx) + }); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) + }); + cx.run_until_parked(); + + let diff_editor = + diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + + assert_state_with_diff( + &diff_editor, + cx, + &" + - ˇoriginal + + modified + " + .unindent(), + ); + + let prev_buffer_hunks = + cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { + let snapshot = buffer_editor.snapshot(window, cx); + let snapshot = &snapshot.buffer_snapshot(); + let prev_buffer_hunks = buffer_editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot) + .collect::>(); + buffer_editor.git_restore(&Default::default(), window, cx); + prev_buffer_hunks + }); + assert_eq!(prev_buffer_hunks.len(), 1); + cx.run_until_parked(); + + let new_buffer_hunks = + cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { + let snapshot = buffer_editor.snapshot(window, cx); + let snapshot = &snapshot.buffer_snapshot(); + buffer_editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot) + .collect::>() }); - }); + assert_eq!(new_buffer_hunks.as_slice(), &[]); - let build_conflict_text: fn(usize) -> String = |tag: usize| { - let mut lines = (0..80) - .map(|line_index| format!("line {line_index}")) - .collect::>(); - for offset in [5usize, 20, 37, 61] { - lines[offset] = format!("base-{tag}-line-{offset}"); - } - format!("{}\n", lines.join("\n")) - }; - let initial_conflict_text = build_conflict_text(0); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "helper.txt": "same\n", - "conflict.txt": initial_conflict_text, - }), - ) - .await; - fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { - state - .refs - .insert("MERGE_HEAD".into(), "conflict-head".into()); + cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { + buffer_editor.set_text("different\n", window, cx); + buffer_editor.save( + SaveOptions { + format: false, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) }) + .await .unwrap(); - fs.set_status_for_repo( - path!("/project/.git").as_ref(), - &[( - "conflict.txt", - FileStatus::Unmerged(UnmergedStatus { - first_head: UnmergedStatusCode::Updated, - second_head: UnmergedStatusCode::Updated, - }), - )], - ); - fs.set_merge_base_content_for_repo( - path!("/project/.git").as_ref(), - &[ - ("conflict.txt", build_conflict_text(1)), - ("helper.txt", "same\n".to_string()), - ], - ); - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let _project_diff = cx - .update(|window, cx| { - ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) - }) - .await - .unwrap(); - cx.run_until_parked(); - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/project/conflict.txt"), cx) - }) - .await - .unwrap(); - buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx)); - assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); cx.run_until_parked(); - cx.update(|window, cx| { - let fs = fs.clone(); - window - .spawn(cx, async move |cx| { - cx.background_executor().simulate_random_delay().await; - fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { - state.refs.insert("HEAD".into(), "head-1".into()); - state.refs.remove("MERGE_HEAD"); - }) - .unwrap(); - fs.set_status_for_repo( - path!("/project/.git").as_ref(), - &[ - ( - "conflict.txt", - FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified, - }), - ), - ( - "helper.txt", - FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified, - }), - ), - ], - ); - // FakeFs assigns deterministic OIDs by entry position; flipping order churns - // conflict diff identity without reaching into ProjectDiff internals. - fs.set_merge_base_content_for_repo( - path!("/project/.git").as_ref(), - &[ - ("helper.txt", "helper-base\n".to_string()), - ("conflict.txt", build_conflict_text(2)), - ], - ); - }) - .detach(); + cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { + buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx); }); - cx.update(|window, cx| { - let buffer = buffer.clone(); - window - .spawn(cx, async move |cx| { - cx.background_executor().simulate_random_delay().await; - for edit_index in 0..10 { - if edit_index > 0 { - cx.background_executor().simulate_random_delay().await; - } - buffer.update(cx, |buffer, cx| { - let len = buffer.len(); - if edit_index % 2 == 0 { - buffer.edit( - [(0..0, format!("status-burst-head-{edit_index}\n"))], - None, - cx, - ); - } else { - buffer.edit( - [(len..len, format!("status-burst-tail-{edit_index}\n"))], - None, - cx, - ); - } - }); - } - }) - .detach(); - }); + assert_state_with_diff( + &buffer_editor, + cx, + &" + - original + + different + ˇ" + .unindent(), + ); - cx.run_until_parked(); + assert_state_with_diff( + &diff_editor, + cx, + &" + - ˇoriginal + + different + " + .unindent(), + ); } #[gpui::test] @@ -2769,7 +2010,7 @@ mod tests { ); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); assert_state_with_diff( &editor, @@ -2814,239 +2055,52 @@ mod tests { }); project .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .await - .unwrap(); - cx.run_until_parked(); - - assert_state_with_diff( - &editor, - cx, - &" - one - - two - + TWO - three - four - five - ˇnine - ten - - eleven - + ELEVEN - twelve - " - .unindent(), - ); - } - - #[gpui::test] - async fn test_sort_by_name_tie_breaks_on_path(cx: &mut TestAppContext) { - init_test(cx); - - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - let git_panel = settings.git_panel.get_or_insert_default(); - git_panel.sort_by = Some(GitPanelSortBy::Name); - git_panel.group_by = Some(GitPanelGroupBy::None); - }); - }); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "lib": { "foo.rs": "LIB FOO\n" }, - "src": { "foo.rs": "SRC FOO\n" }, - "m.rs": "M\n", - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let diff = cx.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) - }); - cx.run_until_parked(); - - fs.set_head_and_index_for_repo( - path!("/project/.git").as_ref(), - &[ - ("lib/foo.rs", "lib foo\n".into()), - ("src/foo.rs", "src foo\n".into()), - ("m.rs", "m\n".into()), - ], - ); - cx.run_until_parked(); - - // Sorted by file name, the two `foo.rs` files come before `m.rs`, and the - // tie between them is broken by the full path (`lib/` before `src/`). - // A plain path sort would instead order them `lib/foo.rs`, `m.rs`, - // `src/foo.rs`. - let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); - assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]); - } - - #[gpui::test] - async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) { - init_test(cx); - - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - let git_panel = settings.git_panel.get_or_insert_default(); - git_panel.tree_view = Some(true); - git_panel.group_by = Some(GitPanelGroupBy::None); - }); - }); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "src": { - "a.rs": "A\n", - "m.rs": "M\n", - "sub": { "b.rs": "B\n" }, - }, - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let diff = cx.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) - }); - cx.run_until_parked(); - - fs.set_head_and_index_for_repo( - path!("/project/.git").as_ref(), - &[ - ("src/a.rs", "a\n".into()), - ("src/m.rs", "m\n".into()), - ("src/sub/b.rs", "b\n".into()), - ], - ); - cx.run_until_parked(); - - // In tree view the `src/sub/` directory sorts before the files directly - // in `src/`. A plain path sort would interleave them as `src/a.rs`, - // `src/m.rs`, `src/sub/b.rs`. - let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); - assert_eq!(paths, vec!["src/sub/b.rs", "src/a.rs", "src/m.rs"]); - } - - #[gpui::test] - async fn test_branch_diff(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "a.txt": "C", - "b.txt": "new", - "c.txt": "in-merge-base-and-work-tree", - "d.txt": "created-in-head", - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let diff = cx - .update(|window, cx| { - ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) - }) - .await - .unwrap(); - cx.run_until_parked(); - - fs.set_head_for_repo( - Path::new(path!("/project/.git")), - &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())], - "sha", - ); - // fs.set_index_for_repo(dot_git, index_state); - fs.set_merge_base_content_for_repo( - Path::new(path!("/project/.git")), - &[ - ("a.txt", "A".into()), - ("c.txt", "in-merge-base-and-work-tree".into()), - ], - ); - cx.run_until_parked(); - - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); - - assert_state_with_diff( - &editor, - cx, - &" - - A - + ˇC - + new - + created-in-head" - .unindent(), - ); - - let statuses: HashMap, Option> = - editor.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .all_buffers() - .iter() - .map(|buffer| { - ( - buffer.read(cx).file().unwrap().path().clone(), - editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx), - ) - }) - .collect() - }); + .await + .unwrap(); + cx.run_until_parked(); - assert_eq!( - statuses, - HashMap::from_iter([ - ( - rel_path("a.txt").into_arc(), - Some(FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified - })) - ), - (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)), - ( - rel_path("d.txt").into_arc(), - Some(FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Added, - worktree_status: git::status::StatusCode::Added - })) - ) - ]) + assert_state_with_diff( + &editor, + cx, + &" + one + - two + + TWO + three + four + five + ˇnine + ten + - eleven + + ELEVEN + twelve + " + .unindent(), ); } #[gpui::test] - async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) { + async fn test_sort_by_name_tie_breaks_on_path(cx: &mut TestAppContext) { init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + let git_panel = settings.git_panel.get_or_insert_default(); + git_panel.sort_by = Some(GitPanelSortBy::Name); + git_panel.group_by = Some(GitPanelGroupBy::None); + }); + }); + }); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, - "a.txt": "changed", + "lib": { "foo.rs": "LIB FOO\n" }, + "src": { "foo.rs": "SRC FOO\n" }, + "m.rs": "M\n", }), ) .await; @@ -3054,213 +2108,156 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let target_branch_diff = cx - .update(|window, cx| { - let Some(repository) = project.read(cx).active_repository(cx) else { - return Task::ready(Err(anyhow!("No active repository"))); - }; - ProjectDiff::new_with_branch_base( - project.clone(), - workspace.clone(), - "topic".into(), - repository, - window, - cx, - ) - }) - .await - .unwrap(); - workspace.update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane( - Box::new(target_branch_diff.clone()), - None, - true, - window, - cx, - ); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) }); cx.run_until_parked(); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(BranchDiff.boxed_clone(), cx); - }); + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[ + ("lib/foo.rs", "lib foo\n".into()), + ("src/foo.rs", "src foo\n".into()), + ("m.rs", "m\n".into()), + ], + ); cx.run_until_parked(); - let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| { - let active_item = workspace.active_item_as::(cx).unwrap(); - let active_base_ref = match active_item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => base_ref.to_string(), - DiffBase::Head => panic!("expected active item to be a branch diff"), - }; - let base_refs = workspace - .items_of_type::(cx) - .filter_map(|item| match item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => Some(base_ref.to_string()), - DiffBase::Head => None, - }) - .collect::>(); - (active_base_ref, base_refs) - }); - base_refs.sort(); - - assert_eq!(active_base_ref, "origin/main"); - assert_eq!(base_refs, vec!["origin/main", "topic"]); + // Sorted by file name, the two `foo.rs` files come before `m.rs`, and the + // tie between them is broken by the full path (`lib/` before `src/`). + // A plain path sort would instead order them `lib/foo.rs`, `m.rs`, + // `src/foo.rs`. + let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); + assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]); } #[gpui::test] - async fn test_update_on_uncommit(cx: &mut TestAppContext) { + async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) { init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + let git_panel = settings.git_panel.get_or_insert_default(); + git_panel.tree_view = Some(true); + git_panel.group_by = Some(GitPanelGroupBy::None); + }); + }); + }); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, - "README.md": "# My cool project\n".to_owned() + "src": { + "a.rs": "A\n", + "m.rs": "M\n", + "sub": { "b.rs": "B\n" }, + }, }), ) .await; - fs.set_head_and_index_for_repo( - Path::new(path!("/project/.git")), - &[("README.md", "# My cool project\n".to_owned())], - ); - let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; - let worktree_id = project.read_with(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }); + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - cx.run_until_parked(); - - let _editor = workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) }); cx.run_until_parked(); - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); fs.set_head_and_index_for_repo( - Path::new(path!("/project/.git")), - &[( - "README.md", - "# My cool project\nDetails to come.\n".to_owned(), - )], + path!("/project/.git").as_ref(), + &[ + ("src/a.rs", "a\n".into()), + ("src/m.rs", "m\n".into()), + ("src/sub/b.rs", "b\n".into()), + ], ); cx.run_until_parked(); - let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - - cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); + // In tree view the `src/sub/` directory sorts before the files directly + // in `src/`. A plain path sort would interleave them as `src/a.rs`, + // `src/m.rs`, `src/sub/b.rs`. + let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); + assert_eq!(paths, vec!["src/sub/b.rs", "src/a.rs", "src/m.rs"]); } #[gpui::test] - async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) { + async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) { init_test(cx); + let git_contents = indoc! {r#" + #[rustfmt::skip] + fn main() { + let x = 0.0; // this line will be removed + // 1 + // 2 + // 3 + let y = 0.0; // this line will be removed + // 1 + // 2 + // 3 + let arr = [ + 0.0, // this line will be removed + 0.0, // this line will be removed + 0.0, // this line will be removed + 0.0, // this line will be removed + ]; + } + "#}; + let buffer_contents = indoc! {" + #[rustfmt::skip] + fn main() { + // 1 + // 2 + // 3 + // 1 + // 2 + // 3 + let arr = [ + ]; + } + "}; + let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/project_a"), - json!({ - ".git": {}, - "a.txt": "CHANGED_A\n", - }), - ) - .await; - fs.insert_tree( - path!("/project_b"), + path!("/a"), json!({ ".git": {}, - "b.txt": "CHANGED_B\n", + "main.rs": buffer_contents, }), ) .await; fs.set_head_and_index_for_repo( - Path::new(path!("/project_a/.git")), - &[("a.txt", "original_a\n".to_string())], - ); - fs.set_head_and_index_for_repo( - Path::new(path!("/project_b/.git")), - &[("b.txt", "original_b\n".to_string())], + Path::new(path!("/a/.git")), + &[("main.rs", git_contents.to_owned())], ); - let project = Project::test( - fs.clone(), - [ - Path::new(path!("/project_a")), - Path::new(path!("/project_b")), - ], - cx, - ) - .await; - - let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| { - let mut worktrees: Vec<_> = project.worktrees(cx).collect(); - worktrees.sort_by_key(|w| w.read(cx).abs_path()); - (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) - }); - + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); - // Select project A explicitly and open the diff. - workspace.update(cx, |workspace, cx| { - let git_store = workspace.project().read(cx).git_store().clone(); - git_store.update(cx, |git_store, cx| { - git_store.set_active_repo_for_worktree(worktree_a_id, cx); - }); - }); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) }); cx.run_until_parked(); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); - let diff_item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); - assert_eq!(paths_a.len(), 1); - assert_eq!(*paths_a[0], *"a.txt"); + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - // Switch the explicit active repository to project B and re-run the diff action. - workspace.update(cx, |workspace, cx| { - let git_store = workspace.project().read(cx).git_store().clone(); - git_store.update(cx, |git_store, cx| { - git_store.set_active_repo_for_worktree(worktree_b_id, cx); - }); - }); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - cx.run_until_parked(); + cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - let same_diff_item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - assert_eq!(diff_item.entity_id(), same_diff_item.entity_id()); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(git::Restore); + cx.dispatch_action(editor::actions::MoveToBeginning); - let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); - assert_eq!(paths_b.len(), 1); - assert_eq!(*paths_b[0], *"b.txt"); + cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); } } diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs index 157ce8316775d9..3c9921c0f96644 100644 --- a/crates/git_ui/src/remote_output.rs +++ b/crates/git_ui/src/remote_output.rs @@ -4,6 +4,17 @@ use git::repository::{Remote, RemoteCommandOutput}; use ui::SharedString; use util::ResultExt as _; +const PULL_REQUEST_HINTS: &[(&str, &str)] = &[ + // GitHub: "Create a pull request for 'branch' on GitHub by visiting:" + ("Create a pull request", "Create Pull Request"), + // Bitbucket: "Create pull request for branch:" + ("Create pull request", "Create Pull Request"), + // GitLab: "To create a merge request for branch, visit:" + ("create a merge request", "Create Merge Request"), + // GitLab: "View merge request for branch:" + ("View merge request", "View Merge Request"), +]; + #[derive(Clone)] pub enum RemoteAction { Fetch(Option), @@ -24,6 +35,7 @@ impl RemoteAction { pub enum SuccessStyle { Toast, ToastWithLog { output: RemoteCommandOutput }, + PushPrLink { label: &'static str, url: String }, } pub struct SuccessMessage { @@ -31,6 +43,42 @@ pub struct SuccessMessage { pub style: SuccessStyle, } +fn extract_pull_request_link(output: &RemoteCommandOutput) -> Option<(&'static str, String)> { + let mut pending_label: Option<&'static str> = None; + + for line in output.stderr.lines() { + let Some(remote_line) = line.trim_start().strip_prefix("remote:") else { + pending_label = None; + continue; + }; + + if let Some((_, label)) = PULL_REQUEST_HINTS + .iter() + .find(|(hint, _)| remote_line.contains(hint)) + { + pending_label = Some(label); + } + + if let Some(url) = extract_url(remote_line) + && let Some(label) = pending_label + { + return Some((label, url)); + } + } + + None +} + +fn extract_url(line: &str) -> Option { + let http_index = line.find("https://").or_else(|| line.find("http://"))?; + let url = line[http_index..] + .split_whitespace() + .next()? + .trim_end_matches(|character| matches!(character, ',' | '.' | ')' | ']' | '>')); + + Some(url.to_string()) +} + pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage { match action { RemoteAction::Fetch(remote) => { @@ -122,6 +170,11 @@ pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> Succ message: "Push: Everything is up-to-date".to_string(), style: SuccessStyle::Toast, } + } else if let Some((label, url)) = extract_pull_request_link(&output) { + SuccessMessage { + message: format!("Pushed {} to {}", branch_name, remote_ref.name), + style: SuccessStyle::PushPrLink { label, url }, + } } else { SuccessMessage { message: format!("Pushed {} to {}", branch_name, remote_ref.name), @@ -161,9 +214,13 @@ mod tests { }; let msg = format_output(&action, output); - - assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. })); - assert_eq!(msg.message, "Pushed test_branch to test_remote"); + if let SuccessStyle::PushPrLink { label, url } = msg.style { + assert_eq!(msg.message, "Pushed test_branch to test_remote"); + assert_eq!(label, "Create Pull Request"); + assert_eq!(url, "https://example.com/test/test/pull/new/test"); + } else { + panic!("Expected PushPrLink variant"); + } } #[test] @@ -191,8 +248,37 @@ mod tests { let msg = format_output(&action, output); - assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. })); - assert_eq!(msg.message, "Pushed test_branch to test_remote"); + if let SuccessStyle::PushPrLink { label, url } = msg.style { + assert_eq!(msg.message, "Pushed test_branch to test_remote"); + assert_eq!(label, "Create Merge Request"); + assert_eq!( + url, + "https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test" + ) + } else { + panic!("Expected PushPrLink variant") + } + } + + #[test] + fn test_push_new_branch_bitbucket_pull_request() { + let output = RemoteCommandOutput { + stdout: String::new(), + stderr: indoc! {" + remote: + remote: Create pull request for test: + remote: https://bitbucket.example.com/projects/TEST/repos/test/pull-requests?create&sourceBranch=refs/heads/test + "} + .to_string(), + }; + + assert_eq!( + extract_pull_request_link(&output), + Some(( + "Create Pull Request", + "https://bitbucket.example.com/projects/TEST/repos/test/pull-requests?create&sourceBranch=refs/heads/test".to_string() + )) + ); } #[test] @@ -206,7 +292,9 @@ mod tests { let output = RemoteCommandOutput { stdout: String::new(), - // Simulate an extraneous link that should not be found in top 3 lines + // Include an unrelated URL outside of the `remote:` lines, in this + // case, an OpenSSH warning, to ensure that it is not mistaken for + // the merge request link. stderr: indoc! {" ** WARNING: connection is not using a post-quantum key exchange algorithm. ** This session may be vulnerable to \"store now, decrypt later\" attacks. @@ -224,8 +312,13 @@ mod tests { let msg = format_output(&action, output); - assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. })); - assert_eq!(msg.message, "Pushed test_branch to test_remote"); + if let SuccessStyle::PushPrLink { label, url } = msg.style { + assert_eq!(msg.message, "Pushed test_branch to test_remote"); + assert_eq!(label, "View Merge Request"); + assert_eq!(url, "https://example.com/test/test/-/merge_requests/99999"); + } else { + panic!("Expected PushPrLink variant") + } } #[test] @@ -254,6 +347,7 @@ mod tests { output.stderr, "To http://example.com/test/test.git\n * [new branch] test -> test\n" ); + assert_eq!(extract_pull_request_link(output), None); } else { panic!("Expected ToastWithLog variant"); } diff --git a/crates/git_ui/src/solo_diff_view.rs b/crates/git_ui/src/solo_diff_view.rs index 90bde497fa1a84..038bde7d0784fc 100644 --- a/crates/git_ui/src/solo_diff_view.rs +++ b/crates/git_ui/src/solo_diff_view.rs @@ -1,18 +1,19 @@ -use crate::{git_panel::GitStatusEntry, git_status_icon}; +use crate::{git_panel::GitStatusEntry, git_panel_settings::GitPanelSettings, git_status_icon}; use anyhow::{Context as _, Result}; use buffer_diff::DiffHunkSecondaryStatus; use editor::{ - Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff, + DiffStyleControls, Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, + ToggleSplitDiff, actions::{GoToHunk, GoToPreviousHunk}, + file_status_label_color, }; -use fs::Fs; use git::{ Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile, repository::RepoPath, status::StageStatus, }; use gpui::{ - Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window, + Action, AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle, + Focusable, HighlightStyle, IntoElement, Render, Subscription, Task, WeakEntity, Window, }; use language::{Anchor, Buffer, HighlightedText, OffsetRangeExt as _, Point}; use multi_buffer::{MultiBuffer, PathKey, excerpt_context_lines}; @@ -20,16 +21,13 @@ use project::{ Project, git_store::{Repository, RepositoryId}, }; -use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file}; +use settings::{Settings, SettingsStore, StatusStyle}; use std::{ any::{Any, TypeId}, ops::Range, sync::Arc, }; -use ui::{ - Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _, - SharedString, Tooltip, prelude::*, vertical_divider, -}; +use ui::{DiffStat, Divider, Tooltip, prelude::*}; use util::paths::{PathExt as _, PathStyle}; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, @@ -437,7 +435,7 @@ impl Item for SoloDiffView { } fn as_searchable(&self, _: &Entity, _: &App) -> Option> { - None + Some(Box::new(self.editor.clone())) } fn for_each_project_item( @@ -479,16 +477,31 @@ impl Item for SoloDiffView { } fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { + let text: SharedString = self + .repo_path + .as_ref() + .display(PathStyle::local()) + .into_owned() + .into(); + + // When the git panel is set to convey status via label color rather + // than an icon, tint the whole path like multibuffer headers do. + let mut highlights = Vec::new(); + if GitPanelSettings::get_global(cx).status_style == StatusStyle::LabelColor + && let Some(status) = self + .repository + .read(cx) + .status_for_path(&self.repo_path) + .map(|entry| entry.status) + { + highlights.push(( + 0..text.len(), + HighlightStyle::color(file_status_label_color(Some(status)).color(cx)), + )); + } + Some(( - vec![HighlightedText { - text: self - .repo_path - .as_ref() - .display(PathStyle::local()) - .into_owned() - .into(), - highlights: Vec::new(), - }], + vec![HighlightedText { text, highlights }], Some( theme_settings::ThemeSettings::get_global(cx) .buffer_font @@ -548,43 +561,6 @@ impl SoloDiffStyleToolbar { self.solo_diff.as_ref()?.upgrade() } - fn set_diff_view_style( - &mut self, - diff_view_style: DiffViewStyle, - window: &mut Window, - cx: &mut Context, - ) { - let Some(solo_diff) = self.solo_diff() else { - return; - }; - let workspace = solo_diff.read(cx).workspace.clone(); - - update_settings_file(::global(cx), cx, move |settings, _| { - settings.editor.diff_view_style = Some(diff_view_style); - }); - - if let Some(workspace) = workspace.upgrade() { - let splittable_editors = { - workspace - .read(cx) - .items(cx) - .filter_map(|item| item.act_as_type(TypeId::of::(), cx)) - .filter_map(|item| item.downcast::().ok()) - .collect::>() - }; - - for editor in splittable_editors { - editor.update(cx, |editor, cx| { - if editor.diff_view_style() != diff_view_style { - editor.toggle_split(&ToggleSplitDiff, window, cx); - } - }); - } - } - - cx.notify(); - } - fn toggle_showing_full_file(&mut self, cx: &mut Context) { if let Some(solo_diff) = self.solo_diff() { solo_diff.update(cx, |solo_diff, cx| { @@ -617,64 +593,49 @@ impl ToolbarItemView for SoloDiffStyleToolbar { impl Render for SoloDiffStyleToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(solo_diff) = self.solo_diff() else { - return div(); + return Empty.into_any_element(); }; - let (editor_entity, showing_full_file) = { + + let (editor_entity, showing_full_file, status) = { let solo_diff = solo_diff.read(cx); - (solo_diff.editor.clone(), solo_diff.showing_full_file) + ( + solo_diff.editor.clone(), + solo_diff.showing_full_file, + solo_diff + .repository + .read(cx) + .status_for_path(&solo_diff.repo_path) + .map(|entry| entry.status), + ) }; - let editor = editor_entity.read(cx); - let diff_view_style = editor.diff_view_style(); - let is_split_set = diff_view_style == DiffViewStyle::Split; - let split_icon = if is_split_set && !editor.is_split() { - IconName::DiffSplitAuto + + let show_status_icon = + GitPanelSettings::get_global(cx).status_style != StatusStyle::LabelColor; + + let (expand_icon, expand_tooltip) = if showing_full_file { + (IconName::ChevronDownUp, "Show Changes Only") } else { - IconName::DiffSplit + (IconName::ChevronUpDown, "Show Full File") }; h_flex() - .h_8() - .items_center() + .pl_0p5() .gap_1() .child( - IconButton::new( - "solo-diff-toggle-excerpts", - if showing_full_file { - IconName::ChevronDownUp - } else { - IconName::ChevronUpDown - }, - ) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text(if showing_full_file { - "Show Changes Only" - } else { - "Show Full File" - })) - .on_click(cx.listener(|this, _, _, cx| { - this.toggle_showing_full_file(cx); - })), - ) - .child( - IconButton::new("solo-diff-unified", IconName::DiffUnified) + IconButton::new("solo-diff-toggle-excerpts", expand_icon) .icon_size(IconSize::Small) - .toggle_state(diff_view_style == DiffViewStyle::Unified) - .tooltip(Tooltip::text("Unified")) - .on_click(cx.listener(|this, _, window, cx| { - this.set_diff_view_style(DiffViewStyle::Unified, window, cx); + .tooltip(Tooltip::text(expand_tooltip)) + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_showing_full_file(cx); })), ) - .child( - IconButton::new("solo-diff-split", split_icon) - .icon_size(IconSize::Small) - .toggle_state(diff_view_style == DiffViewStyle::Split) - .tooltip(Tooltip::text("Split")) - .on_click(cx.listener(|this, _, window, cx| { - this.set_diff_view_style(DiffViewStyle::Split, window, cx); - })), + .child(DiffStyleControls::new(editor_entity)) + .child(Divider::vertical().mr_1()) + .when_some( + show_status_icon.then_some(status).flatten(), + |this, status| this.child(git_status_icon(status)), ) - .child(vertical_divider()) - .child(div().w_1()) + .into_any_element() } } @@ -745,8 +706,9 @@ struct SoloDiffButtonStates { impl Render for SoloDiffGitToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(solo_diff) = self.solo_diff() else { - return div(); + return gpui::Empty.into_any_element(); }; + let focus_handle = solo_diff.focus_handle(cx); let solo_diff = solo_diff.read(cx); let button_states = solo_diff.button_states(cx); @@ -754,32 +716,59 @@ impl Render for SoloDiffGitToolbar { .repository .read(cx) .status_for_path(&solo_diff.repo_path); - let status = status_entry.as_ref().map(|entry| entry.status); let diff_stat = status_entry.and_then(|entry| entry.diff_stat); - h_group_xl() + h_flex() .my_neg_1() .py_1() - .items_center() + .gap_1p5() .flex_wrap() .justify_between() - .children(status.map(|status| git_status_icon(status).into_any_element())) .children(diff_stat.map(|stat| { DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize) - .into_any_element() })) + .child(Divider::vertical().ml_1()) + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) .child(Divider::vertical()) .child( h_group_sm() .when(button_states.selection, |el| { el.child( Button::new("stage", "Toggle Staged") + .disabled(!button_states.stage && !button_states.unstage) .tooltip(Tooltip::for_action_title_in( "Toggle Staged", &ToggleStaged, &focus_handle, )) - .disabled(!button_states.stage && !button_states.unstage) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&ToggleStaged, window, cx) })), @@ -788,24 +777,24 @@ impl Render for SoloDiffGitToolbar { .when(!button_states.selection, |el| { el.child( Button::new("stage", "Stage") + .disabled(!button_states.stage) .tooltip(Tooltip::for_action_title_in( - "Stage and go to next hunk", + "Stage and Go to Next Hunk", &StageAndNext, &focus_handle, )) - .disabled(!button_states.stage) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&StageAndNext, window, cx) })), ) .child( Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) .tooltip(Tooltip::for_action_title_in( - "Unstage and go to next hunk", + "Unstage and Go to Next Hunk", &UnstageAndNext, &focus_handle, )) - .disabled(!button_states.unstage) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&UnstageAndNext, window, cx) })), @@ -824,72 +813,40 @@ impl Render for SoloDiffGitToolbar { })), ), ) + .child(Divider::vertical()) + .child(h_group_sm().child(if button_states.stage_file { + Button::new("stage-file", "Stage All") + .width(rems_from_px(80.)) + .disabled(!button_states.stage_file) + .tooltip(Tooltip::for_action_title_in( + "Stage All", + &StageFile, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.stage_file(window, cx))) + } else { + Button::new("unstage-file", "Unstage All") + .width(rems_from_px(80.)) + .disabled(!button_states.unstage_file) + .tooltip(Tooltip::for_action_title_in( + "Unstage All", + &UnstageFile, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.unstage_file(window, cx))) + })) + .child(Divider::vertical()) .child( - h_group_sm() - .child( - IconButton::new("up", IconName::ArrowUp) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::for_action_title_in( - "Go to previous hunk", - &GoToPreviousHunk, - &focus_handle, - )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToPreviousHunk, window, cx) - })), - ) - .child( - IconButton::new("down", IconName::ArrowDown) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::for_action_title_in( - "Go to next hunk", - &GoToHunk, - &focus_handle, - )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToHunk, window, cx) - })), - ), - ) - .child(vertical_divider()) - .child( - h_group_sm() - .child(if button_states.stage_file { - Button::new("stage-file", "Stage File") - .tooltip(Tooltip::for_action_title_in( - "Stage file", - &StageFile, - &focus_handle, - )) - .disabled(!button_states.stage_file) - .on_click( - cx.listener(|this, _, window, cx| this.stage_file(window, cx)), - ) - } else { - Button::new("unstage-file", "Unstage File") - .tooltip(Tooltip::for_action_title_in( - "Unstage file", - &UnstageFile, - &focus_handle, - )) - .disabled(!button_states.unstage_file) - .on_click( - cx.listener(|this, _, window, cx| this.unstage_file(window, cx)), - ) - }) - .child( - Button::new("commit", "Commit") - .tooltip(Tooltip::for_action_title_in( - "Commit", - &Commit, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&Commit, window, cx); - })), - ), + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), ) + .into_any_element() } } diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs new file mode 100644 index 00000000000000..30ab5bd0590c97 --- /dev/null +++ b/crates/git_ui/src/staged_diff.rs @@ -0,0 +1,1098 @@ +use crate::{ + diff_multibuffer::DiffMultibuffer, + git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, +}; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkStatus; +use editor::{ + DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor, + actions::{GoToHunk, GoToPreviousHunk}, +}; +use git::{Commit, UnstageAll, UnstageAndNext}; +use gpui::{ + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::Capability; +use project::{ + Project, ProjectPath, + git_store::diff_buffer_list::{DiffBase, DiffBufferList}, + project_settings::ProjectSettings, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + ops::Range, + sync::Arc, +}; +use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*}; +use util::ResultExt as _; +use workspace::{ + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, + item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + searchable::SearchableItemHandle, +}; + +pub(crate) struct StagedDiffDelegate; + +impl DiffHunkDelegate for StagedDiffDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.stage_or_unstage(false, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + if stage { + return; + } + let Some(project) = editor.project().cloned() else { + return; + }; + for hunks in hunks { + let index_ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if index_ranges.is_empty() { + continue; + } + project + .update(cx, |project, cx| { + project.unstage_staged_hunks(hunks.diff, index_ranges, cx) + }) + .log_err(); + } + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + _is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + if !ProjectSettings::get_global(cx) + .git + .show_stage_restore_buttons + { + return gpui::Empty.into_any_element(); + } + let hunk_range = hunk_range.start..hunk_range.start; + 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) + .block_mouse_except_scroll() + .shadow_md() + .child( + Button::new(("unstage", row as u64), "Unstage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip(Tooltip::text("Unstage Hunk")) + .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.clone()], + window, + cx, + ); + }); + } + }), + ) + .into_any_element() + } +} + +/// The workspace item for the staged diff. It wraps a single read-only +/// [`DiffMultibuffer`] over [`DiffBase::Staged`] and delegates the [`Item`] +/// surface to it. +pub struct StagedDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +impl StagedDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + let _ = workspace; + workspace::register_serializable_item::(cx); + } + + pub fn deploy_at( + workspace: &mut Workspace, + entry: Option, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!( + "Git Staged Diff Opened", + source = if entry.is_some() { + "Git Panel" + } else { + "Action" + } + ); + let intended_repo = workspace.project().read(cx).active_repository(cx); + let existing = workspace.items_of_type::(cx).next(); + let staged_diff = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let workspace_handle = cx.entity(); + let staged_diff = + cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + workspace.add_item_to_active_pane( + Box::new(staged_diff.clone()), + None, + true, + window, + cx, + ); + staged_diff + }; + + if let Some(intended) = &intended_repo { + let needs_switch = staged_diff + .read(cx) + .diff + .read(cx) + .repo(cx) + .map_or(true, |current| current.entity_id() != intended.entity_id()); + if needs_switch { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.diff.update(cx, |diff, cx| { + diff.set_repo(Some(intended.clone()), cx); + }); + }); + } + } + + if let Some(entry) = entry { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.move_to_entry(entry, window, cx); + }); + } + } + + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + } + + pub(crate) fn new( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| DiffBufferList::new(DiffBase::Staged, project.clone(), window, cx)); + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadOnly, + "No staged changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(StagedDiffDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(true); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + pub(crate) fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + fn button_states(&self, cx: &App) -> ButtonStates { + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); + let (selection, ranges) = diff.selected_ranges(cx); + let unstage = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .next() + .is_some(); + let mut unstage_all = false; + self.workspace + .read_with(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + unstage_all = git_panel.read(cx).can_unstage_all(); + } + }) + .ok(); + + ButtonStates { + unstage, + prev_next, + selection, + unstage_all, + } + } + + fn unstage_selected_staged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.stage_or_unstage_selected_hunks(false, move_to_next, window, cx) + }); + } +} + +struct ButtonStates { + unstage: bool, + prev_next: bool, + selection: bool, + unstage_all: bool, +} + +impl EventEmitter for StagedDiff {} + +impl Focusable for StagedDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for StagedDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + Some("Staged Changes".into()) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, _cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Staged Changes".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Git Staged Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx)))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &App) -> bool { + false + } + + fn save( + &mut self, + _: SaveOptions, + _: Entity, + _: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl SerializableItem for StagedDiff { + fn serialized_item_kind() -> &'static str { + "StagedDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + _: workspace::WorkspaceId, + _: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))? + }) + } + + fn serialize( + &mut self, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option>> { + Some(Task::ready(Ok(()))) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +impl Render for StagedDiff { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.diff.clone() + } +} + +pub struct StagedDiffToolbar { + staged_diff: Option>, + workspace: WeakEntity, +} + +impl StagedDiffToolbar { + pub fn new(workspace: &Workspace, _: &mut Context) -> Self { + Self { + staged_diff: None, + workspace: workspace.weak_handle(), + } + } + + fn staged_diff(&self, _: &App) -> Option> { + self.staged_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(staged_diff) = self.staged_diff(cx) { + staged_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } + + fn unstage_selected_staged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(staged_diff) = self.staged_diff(cx) else { + return; + }; + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.unstage_selected_staged_hunks(move_to_next, window, cx); + }); + } + + fn unstage_all(&mut self, window: &mut Window, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.unstage_all(&Default::default(), window, cx); + }); + }) + .ok(); + } +} + +impl EventEmitter for StagedDiffToolbar {} + +impl ToolbarItemView for StagedDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.staged_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.staged_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for StagedDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(staged_diff) = self.staged_diff(cx) else { + return div(); + }; + let focus_handle = staged_diff.focus_handle(cx); + let button_states = staged_diff.read(cx).button_states(cx); + + let diff = staged_diff.read(cx).diff.read(cx); + let (additions, deletions) = diff.calculate_changed_lines(cx); + let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty(); + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "staged-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + h_group_sm() + .when(button_states.selection, |this| { + this.child( + Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) + .tooltip(Tooltip::text("Unstage Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.unstage_selected_staged_hunks(false, window, cx) + })), + ) + }) + .when(!button_states.selection, |this| { + this.child( + Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) + .tooltip(Tooltip::for_action_title_in( + "Unstage and Go to Next Hunk", + &UnstageAndNext, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.unstage_selected_staged_hunks(true, window, cx) + })), + ) + }), + ) + .child(Divider::vertical()) + .child( + Button::new("unstage-all", "Unstage All") + .width(rems_from_px(80.)) + .disabled(!button_states.unstage_all) + .tooltip(Tooltip::for_action_title_in( + "Unstage All Changes", + &UnstageAll, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.unstage_all(window, cx))), + ) + .child(Divider::vertical()) + .child( + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), + ) + } +} + +#[cfg(test)] +mod tests { + use crate::project_diff::{self, ProjectDiff}; + use git::repository::RepoPath; + use gpui::{Action as _, TestAppContext}; + use language::Point; + use project::{FakeFs, Fs as _}; + use serde_json::json; + use settings::{DiffViewStyle, SettingsStore}; + use std::path::Path; + use unindent::Unindent as _; + use util::{path, rel_path::rel_path}; + use workspace::MultiWorkspace; + + use super::*; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Unified); + }); + }); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + #[gpui::test] + async fn test_staged_changes_deploy_as_a_separate_staged_diff_item(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents.clone(), + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents.clone())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let uncommitted_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + + workspace.update_in(cx, |workspace, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + let staged_diff = workspace.active_item_as::(cx).unwrap(); + assert_ne!(staged_diff.entity_id(), uncommitted_item.entity_id()); + let staged_item = workspace + .active_item(cx) + .unwrap() + .act_as::(cx) + .unwrap(); + assert_ne!(staged_item.entity_id(), uncommitted_item.entity_id()); + assert_eq!( + staged_item.read_with(cx, |diff, cx| diff.diff_base(cx).clone()), + DiffBase::Staged + ); + assert!(staged_item.read_with(cx, |diff, cx| diff.multibuffer().read(cx).read_only())); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + + let active_item = workspace.active_item(cx).unwrap(); + assert!(active_item.act_as::(cx).is_some()); + assert!(active_item.act_as::(cx).is_some()); + assert_eq!( + active_item + .to_serializable_item_handle(cx) + .unwrap() + .serialized_item_kind(), + "StagedDiff" + ); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + assert!(!active_item.can_save(cx)); + }); + } + + #[gpui::test] + async fn test_toggle_staged_unstages_from_staged_view(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents.clone())], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + let repo = fs + .open_repo(path!("/project/.git").as_ref(), Some("git".as_ref())) + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }); + cx.run_until_parked(); + + let editor = workspace.update(cx, |workspace, cx| { + let staged_diff = workspace.active_item_as::(cx).unwrap(); + let staged_diff = staged_diff.read(cx); + staged_diff + .diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 1 + ); + }); + + // Hold back FS events so the first assertions observe the optimistic + // state rather than a reloaded diff. + fs.pause_events(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); + }); + }); + cx.focus(&editor); + cx.update(|window, cx| { + window.dispatch_action(git::ToggleStaged.boxed_clone(), cx); + }); + cx.run_until_parked(); + + // The hunk is optimistically suppressed from the staged view, and the + // index write has landed. + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 0 + ); + }); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("src/main.rs"))) + .await + .unwrap(), + committed_contents + ); + + fs.unpause_events_and_flush(); + cx.run_until_parked(); + + // Once the write is reconciled, the staged view remains empty. + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 0 + ); + }); + } + + #[gpui::test] + async fn test_staged_diff_restores_as_staged_diff(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + let project = workspace.update(cx, |workspace, _| workspace.project().clone()); + let workspace_id = workspace::WorkspaceId::from_i64(1); + let item_id = 42; + + let restore_task = workspace.update_in(cx, |_workspace, window, cx| { + ::deserialize( + project.clone(), + cx.entity().downgrade(), + workspace_id, + item_id, + window, + cx, + ) + }); + let restored_staged_diff = restore_task.await.unwrap(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(restored_staged_diff.clone()), + None, + true, + window, + cx, + ); + }); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item(cx).unwrap(); + assert!(active_item.act_as::(cx).is_some()); + assert!(active_item.act_as::(cx).is_some()); + assert_eq!( + active_item + .to_serializable_item_handle(cx) + .unwrap() + .serialized_item_kind(), + "StagedDiff" + ); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + assert!(!active_item.can_save(cx)); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + let diff = active_item.act_as::(cx).unwrap(); + assert_eq!( + diff.read_with(cx, |diff, cx| diff.diff_base(cx).clone()), + DiffBase::Staged + ); + }); + } +} diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 5312ae6d08814d..c9cf6b5f537915 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -3,8 +3,8 @@ use anyhow::Result; use buffer_diff::BufferDiff; use editor::{ - Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint, - actions::DiffClipboardWithSelectionData, + Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + SplittableEditor, ToPoint, actions::DiffClipboardWithSelectionData, }; use futures::{FutureExt, select_biased}; use gpui::{ @@ -184,11 +184,8 @@ impl TextDiffView { window, cx, ); - splittable.disable_diff_hunk_controls(cx); - splittable.set_render_diff_hunks_as_unstaged(cx); - splittable.rhs_editor().update(cx, |editor, _cx| { - editor.start_temporary_diff_override(); - }); + splittable + .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); splittable }); @@ -221,7 +218,7 @@ impl TextDiffView { .file() .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned()) }) - .unwrap_or("untitled".into()); + .unwrap_or(MultiBuffer::DEFAULT_TITLE.into()); let selection_location_path = selection_location_text .map(|text| format!("{} @ {}", path, text)) diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs new file mode 100644 index 00000000000000..02cb8f12c24d45 --- /dev/null +++ b/crates/git_ui/src/unstaged_diff.rs @@ -0,0 +1,847 @@ +use crate::{ + diff_multibuffer::DiffMultibuffer, + git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, +}; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkStatus; +use editor::{ + DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor, + actions::{GoToHunk, GoToPreviousHunk}, +}; +use git::{StageAll, StageAndNext}; +use gpui::{ + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::Capability; +use project::{ + Project, ProjectPath, + git_store::diff_buffer_list::{DiffBase, DiffBufferList}, + project_settings::ProjectSettings, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + ops::Range, + sync::Arc, +}; +use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*}; +use util::ResultExt as _; +use workspace::{ + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, + item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + searchable::SearchableItemHandle, +}; + +pub(crate) struct UnstagedDiffDelegate; + +impl DiffHunkDelegate for UnstagedDiffDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.stage_or_unstage(true, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + if !stage { + return; + } + let Some(project) = editor.project().cloned() else { + return; + }; + for hunks in hunks { + let Some(buffer) = hunks.buffer else { + continue; + }; + let worktree_ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if worktree_ranges.is_empty() { + continue; + } + project + .update(cx, |project, cx| { + project.stage_hunks(buffer, hunks.diff, worktree_ranges, cx) + }) + .log_err(); + } + } + + fn restore( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + if hunks.is_empty() || editor.read_only(cx) { + return; + } + editor.transact(window, cx, |editor, window, cx| { + editor.restore_diff_hunks(hunks, cx); + let selections = editor + .selections + .all::(&editor.display_snapshot(cx)); + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections_state| { + selections_state.select(selections); + }, + ); + }); + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + if !ProjectSettings::get_global(cx) + .git + .show_stage_restore_buttons + { + return gpui::Empty.into_any_element(); + } + let hunk_range_for_restore = hunk_range.clone(); + let hunk_range = hunk_range.start..hunk_range.start; + 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) + .block_mouse_except_scroll() + .shadow_md() + .child( + Button::new(("stage", row as u64), "Stage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip(Tooltip::text("Stage Hunk")) + .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.clone()], + window, + cx, + ); + }); + } + }), + ) + .child( + Button::new(("restore", row as u64), "Restore") + .tooltip(Tooltip::text("Restore Hunk")) + .on_click({ + let editor = editor.clone(); + let hunk_range = hunk_range_for_restore; + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let hunks: Vec<_> = editor + .diff_hunks_in_ranges( + std::slice::from_ref(&hunk_range), + &snapshot, + ) + .collect(); + if !hunks.is_empty() { + editor.apply_restore(hunks, window, cx); + } + }); + } + }) + .disabled(is_created_file), + ) + .into_any_element() + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } +} + +pub struct UnstagedDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +impl UnstagedDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + let _ = workspace; + workspace::register_serializable_item::(cx); + } + + pub fn deploy_at( + workspace: &mut Workspace, + entry: Option, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!( + "Git Unstaged Diff Opened", + source = if entry.is_some() { + "Git Panel" + } else { + "Action" + } + ); + let intended_repo = workspace.project().read(cx).active_repository(cx); + let existing = workspace.items_of_type::(cx).next(); + let unstaged_diff = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let workspace_handle = cx.entity(); + let unstaged_diff = + cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + workspace.add_item_to_active_pane( + Box::new(unstaged_diff.clone()), + None, + true, + window, + cx, + ); + unstaged_diff + }; + + if let Some(intended) = &intended_repo { + let needs_switch = unstaged_diff + .read(cx) + .diff + .read(cx) + .repo(cx) + .map_or(true, |current| current.entity_id() != intended.entity_id()); + if needs_switch { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.diff.update(cx, |diff, cx| { + diff.set_repo(Some(intended.clone()), cx); + }); + }); + } + } + + if let Some(entry) = entry { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.move_to_entry(entry, window, cx); + }); + } + } + + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + } + + pub(crate) fn new( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| DiffBufferList::new(DiffBase::Index, project.clone(), window, cx)); + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No unstaged changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(UnstagedDiffDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + pub(crate) fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + fn button_states(&self, cx: &App) -> ButtonStates { + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); + let (selection, ranges) = diff.selected_ranges(cx); + let stage = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .next() + .is_some(); + let restore = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .any(|h| !h.is_created_file()); + let mut stage_all = false; + self.workspace + .read_with(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + stage_all = git_panel.read(cx).can_stage_all(); + } + }) + .ok(); + let restore_all = snapshot.diff_hunks().any(|h| !h.is_created_file()); + + ButtonStates { + stage, + restore, + restore_all, + prev_next, + selection, + stage_all, + } + } + + fn stage_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.stage_or_unstage_selected_hunks(true, move_to_next, window, cx) + }); + } + + fn restore_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.restore_selected_hunks(move_to_next, window, cx) + }); + } +} + +struct ButtonStates { + stage: bool, + restore: bool, + restore_all: bool, + prev_next: bool, + selection: bool, + stage_all: bool, +} + +impl EventEmitter for UnstagedDiff {} + +impl Focusable for UnstagedDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for UnstagedDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + Some("Unstaged Changes".into()) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, _cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Unstaged Changes".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Git Unstaged Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx)))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _cx: &App) -> bool { + true + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl SerializableItem for UnstagedDiff { + fn serialized_item_kind() -> &'static str { + "UnstagedDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + _: workspace::WorkspaceId, + _: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))? + }) + } + + fn serialize( + &mut self, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option>> { + Some(Task::ready(Ok(()))) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +impl Render for UnstagedDiff { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.diff.clone() + } +} + +pub struct UnstagedDiffToolbar { + unstaged_diff: Option>, + workspace: WeakEntity, +} + +impl UnstagedDiffToolbar { + pub fn new(workspace: &Workspace, _: &mut Context) -> Self { + Self { + unstaged_diff: None, + workspace: workspace.weak_handle(), + } + } + + fn unstaged_diff(&self, _: &App) -> Option> { + self.unstaged_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(unstaged_diff) = self.unstaged_diff(cx) { + unstaged_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } + + fn stage_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return; + }; + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.stage_selected_unstaged_hunks(move_to_next, window, cx); + }); + } + + fn restore_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return; + }; + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.restore_selected_unstaged_hunks(move_to_next, window, cx); + }); + } + + fn stage_all(&mut self, window: &mut Window, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.stage_all(&Default::default(), window, cx); + }); + }) + .ok(); + } + + fn restore_all(&mut self, window: &mut Window, cx: &mut Context) { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return; + }; + let diff = unstaged_diff.read(cx).diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let hunks: Vec<_> = snapshot + .diff_hunks() + .filter(|h| !h.is_created_file()) + .collect(); + if !hunks.is_empty() { + editor.update(cx, |editor, cx| { + editor.apply_restore(hunks, window, cx); + }); + } + } +} + +impl EventEmitter for UnstagedDiffToolbar {} + +impl ToolbarItemView for UnstagedDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.unstaged_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.unstaged_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for UnstagedDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return div(); + }; + let focus_handle = unstaged_diff.focus_handle(cx); + let button_states = unstaged_diff.read(cx).button_states(cx); + + let diff = unstaged_diff.read(cx).diff.read(cx); + let (additions, deletions) = diff.calculate_changed_lines(cx); + let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty(); + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "unstaged-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + h_group_sm() + .when(button_states.selection, |this| { + this.child( + Button::new("stage", "Stage") + .disabled(!button_states.stage) + .tooltip(Tooltip::text("Stage Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.stage_selected_unstaged_hunks(false, window, cx) + })), + ) + }) + .when(!button_states.selection, |this| { + this.child( + Button::new("stage", "Stage") + .disabled(!button_states.stage) + .tooltip(Tooltip::for_action_title_in( + "Stage and Go to Next Hunk", + &StageAndNext, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.stage_selected_unstaged_hunks(true, window, cx) + })), + ) + }) + .child( + Button::new("restore", "Restore") + .disabled(!button_states.restore) + .tooltip(Tooltip::text("Restore Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.restore_selected_unstaged_hunks(false, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + Button::new("stage-all", "Stage All") + .width(rems_from_px(80.)) + .disabled(!button_states.stage_all) + .tooltip(Tooltip::for_action_title_in( + "Stage All Changes", + &StageAll, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.stage_all(window, cx))), + ) + .child(Divider::vertical()) + .child( + Button::new("restore-all", "Restore All") + .width(rems_from_px(80.)) + .disabled(!button_states.restore_all) + .tooltip(Tooltip::text("Restore All Changes")) + .on_click(cx.listener(|this, _, window, cx| this.restore_all(window, cx))), + ) + } +} diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index df0e8992751ad0..9d23880fbc46a9 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -26,7 +26,8 @@ use workspace::{ use crate::git_panel::show_error_toast; use crate::worktree_service::{RemoteBranchName, WorktreeCreateTarget, worktree_create_targets}; use zed_actions::{ - CreateWorktree, NewWorktreeBranchTarget, OpenWorktreeInNewWindow, SwitchWorktree, + CreateWorktree, NewWorktreeBranchTarget, OpenWorktreeInNewWindow, OpenWorktreeSetupTasks, + SwitchWorktree, }; actions!( @@ -246,6 +247,10 @@ impl Render for WorktreePicker { .on_mouse_down_out(cx.listener(|_, _, _, cx| { cx.emit(DismissEvent); })) + .on_action(cx.listener(|_, _: &OpenWorktreeSetupTasks, _, cx| { + cx.emit(DismissEvent); + cx.propagate(); + })) .on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| { this.picker.update(cx, |picker, cx| { let ix = picker.delegate.selected_index; @@ -1348,6 +1353,35 @@ impl PickerDelegate for WorktreePickerDelegate { } } + fn searchbar_trailer( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + if self.show_footer { + return None; + } + + let focus_handle = self.focus_handle.clone(); + + Some( + IconButton::new("configure-worktree-tasks", IconName::Settings) + .icon_size(IconSize::Small) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Automate Worktree Setup", + &OpenWorktreeSetupTasks, + &focus_handle, + cx, + ) + }) + .on_click(|_, window, cx| { + window.dispatch_action(OpenWorktreeSetupTasks.boxed_clone(), cx) + }) + .into_any_element(), + ) + } + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { if !self.show_footer { return None; @@ -1384,9 +1418,19 @@ impl PickerDelegate for WorktreePickerDelegate { .w_full() .p_1p5() .gap_0p5() - .justify_end() + .justify_between() .border_t_1() - .border_color(cx.theme().colors().border_variant); + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("configure-worktree-tasks", "Automate Setup") + .key_binding( + KeyBinding::for_action_in(&OpenWorktreeSetupTasks, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(OpenWorktreeSetupTasks.boxed_clone(), cx) + }), + ); if is_creating { Some( @@ -1406,55 +1450,70 @@ impl PickerDelegate for WorktreePickerDelegate { } else if is_existing_worktree { Some( footer - .when(is_deleting, |this| { - this.child( - Button::new("delete-worktree", "Deleting…") - .loading(true) - .disabled(true), - ) - }) - .when(!is_deleting && can_delete, |this| { - let focus_handle = focus_handle.clone(); - 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.))), + .child( + h_flex() + .gap_0p5() + .when(is_deleting, |this| { + this.child( + Button::new("delete-worktree", "Deleting…") + .loading(true) + .disabled(true), ) - .on_click(|_, window, cx| { - window.dispatch_action(DeleteWorktree.boxed_clone(), cx) - }), - ) - }) - .when(!is_deleting && !is_current, |this| { - let focus_handle = focus_handle.clone(); - this.child( - Button::new("open-in-new-window", "Open in New Window") - .key_binding( - KeyBinding::for_action_in( - &menu::SecondaryConfirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + }) + .when(!is_deleting && can_delete, |this| { + let focus_handle = focus_handle.clone(); + 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) + }), ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) - }), - ) - }) - .when(!is_deleting, |this| { - this.child( - Button::new("open-worktree", "Open") - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), + }) + .when(!is_deleting && !is_current, |this| { + let focus_handle = focus_handle.clone(); + this.child( + Button::new("open-in-new-window", "Open in New Window") + .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, + ) + }), ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }), - ) - }) + }) + .when(!is_deleting, |this| { + this.child( + Button::new("open-worktree", "Open") + .key_binding( + KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + }), + ) .into_any(), ) } else { diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index f1f3a61c977f56..29f1c0af1384ea 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -225,6 +225,11 @@ impl Render for CursorPosition { el.child( Button::new("go-to-line-column", text) .label_size(LabelSize::Small) + .tab_index(0isize) + .aria_label(format!( + "Line {}, column {}", + position.line, position.character + )) .on_click(cx.listener(|this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { workspace.update(cx, |workspace, cx| { diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs index ab0711564834d2..16c4f86eb41625 100644 --- a/crates/google_ai/src/completion.rs +++ b/crates/google_ai/src/completion.rs @@ -2,8 +2,9 @@ use anyhow::Result; use futures::{Stream, StreamExt}; use language_model_core::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, - StopReason, TokenUsage, + LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolUse, + LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason, + TokenUsage, }; use std::pin::Pin; use std::sync::Arc; @@ -20,20 +21,18 @@ pub fn into_google( mut request: LanguageModelRequest, model_id: String, mode: GoogleModelMode, -) -> crate::GenerateContentRequest { - fn map_content(content: Vec) -> Vec { - content - .into_iter() - .flat_map(|content| match content { +) -> Result { + fn map_content(content: Vec) -> Result> { + let mut mapped_parts = Vec::new(); + for content in content { + match content { MessageContent::Text(text) => { if !text.is_empty() { - vec![Part::TextPart(TextPart { + mapped_parts.push(Part::TextPart(TextPart { text, thought: false, thought_signature: None, - })] - } else { - vec![] + })); } } MessageContent::Thinking { @@ -41,39 +40,37 @@ pub fn into_google( signature: Some(signature), } => { if !signature.is_empty() { - vec![Part::TextPart(TextPart { + mapped_parts.push(Part::TextPart(TextPart { text, thought: true, thought_signature: Some(signature), - })] - } else { - vec![] + })); } } - MessageContent::Thinking { .. } => { - vec![] - } - MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => vec![], + MessageContent::Thinking { .. } => {} + MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => {} MessageContent::Image(image) => { - vec![Part::InlineDataPart(InlineDataPart { + mapped_parts.push(Part::InlineDataPart(InlineDataPart { inline_data: GenerativeContentBlob { mime_type: "image/png".to_string(), data: image.source.to_string(), }, - })] + })); } MessageContent::ToolUse(tool_use) => { - // Normalize empty string signatures to None let thought_signature = tool_use.thought_signature.filter(|s| !s.is_empty()); + let LanguageModelToolUseInput::Json(input) = tool_use.input else { + anyhow::bail!("Google AI does not support custom tool calls"); + }; - vec![Part::FunctionCallPart(crate::FunctionCallPart { + mapped_parts.push(Part::FunctionCallPart(crate::FunctionCallPart { function_call: crate::FunctionCall { name: tool_use.name.to_string(), - args: tool_use.input, + args: input, id: Some(tool_use.id.to_string()), }, thought_signature, - })] + })); } MessageContent::ToolResult(tool_result) => { let mut text_output = String::new(); @@ -98,7 +95,7 @@ pub fn into_google( } else { text_output }; - let mut parts = vec![Part::FunctionResponsePart(crate::FunctionResponsePart { + mapped_parts.push(Part::FunctionResponsePart(crate::FunctionResponsePart { function_response: crate::FunctionResponse { name: tool_result.tool_name.to_string(), // The API expects a valid JSON object @@ -107,12 +104,12 @@ pub fn into_google( }), id: Some(tool_result.tool_use_id.to_string()), }, - })]; - parts.extend(images.into_iter().map(Part::InlineDataPart)); - parts + })); + mapped_parts.extend(images.into_iter().map(Part::InlineDataPart)); } - }) - .collect() + } + } + Ok(mapped_parts) } let thinking_config = thinking_config_for_request(&request, &model_id, mode); @@ -124,33 +121,59 @@ pub fn into_google( { let message = request.messages.remove(0); Some(SystemInstruction { - parts: map_content(message.content), + parts: map_content(message.content)?, }) } else { None }; - crate::GenerateContentRequest { + let tools = if request.tools.is_empty() { + None + } else { + Some(vec![crate::Tool { + function_declarations: request + .tools + .into_iter() + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(FunctionDeclaration { + name: tool.name, + description: tool.description, + parameters: input_schema, + }) + } + LanguageModelRequestToolInput::Custom { .. } => { + Err(anyhow::anyhow!("Google AI does not support custom tools")) + } + }) + .collect::>()?, + }]) + }; + + Ok(crate::GenerateContentRequest { model: ModelName { model_id }, system_instruction: system_instructions, contents: request .messages .into_iter() - .filter_map(|message| { - let parts = map_content(message.content); + .map(|message| { + let parts = map_content(message.content)?; if parts.is_empty() { - None + Ok(None) } else { - Some(Content { + Ok(Some(Content { parts, role: match message.role { Role::User => crate::Role::User, Role::Assistant => crate::Role::Model, Role::System => crate::Role::User, // Google AI doesn't have a system role }, - }) + })) } }) + .collect::>>()? + .into_iter() + .flatten() .collect(), generation_config: Some(GenerationConfig { candidate_count: Some(1), @@ -162,19 +185,7 @@ pub fn into_google( top_k: None, }), safety_settings: None, - tools: (!request.tools.is_empty()).then(|| { - vec![crate::Tool { - function_declarations: request - .tools - .into_iter() - .map(|tool| FunctionDeclaration { - name: tool.name, - description: tool.description, - parameters: tool.input_schema, - }) - .collect(), - }] - }), + tools, tool_config: request.tool_choice.map(|choice| ToolConfig { function_calling_config: FunctionCallingConfig { mode: match choice { @@ -185,7 +196,7 @@ pub fn into_google( allowed_function_names: None, }, }), - } + }) } fn thinking_config_for_request( @@ -404,7 +415,9 @@ impl GoogleEventMapper { name, is_input_complete: true, raw_input: function_call_part.function_call.args.to_string(), - input: function_call_part.function_call.args, + input: LanguageModelToolUseInput::Json( + function_call_part.function_call.args, + ), thought_signature, }, ))); @@ -493,7 +506,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.include_thoughts, Some(true)); @@ -515,7 +529,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.thinking_budget, Some(0)); @@ -533,7 +548,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Minimal)); @@ -561,7 +577,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let Part::TextPart(text_part) = &request.contents[0].parts[0] else { panic!("expected text part"); diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index fa6fcace40f376..8fd4a7c7639a74 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -29,9 +29,7 @@ test-support = [ bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] -wayland = [ - "bitflags", -] +wayland = [] x11 = [ "scap?/x11", ] @@ -51,7 +49,7 @@ accesskit.workspace = true anyhow.workspace = true async-task = "4.7" backtrace = { workspace = true, optional = true } -bitflags = { workspace = true, optional = true } +bitflags.workspace = true collections.workspace = true criterion = { workspace = true, optional = true } @@ -75,7 +73,7 @@ proptest = { workspace = true, optional = true } chrono.workspace = true profiling.workspace = true rand.workspace = true -raw-window-handle = "0.6" +raw-window-handle.workspace = true regex.workspace = true refineable.workspace = true scheduler.workspace = true @@ -93,7 +91,7 @@ async-channel.workspace = true stacksafe.workspace = true strum.workspace = true sum_tree.workspace = true -taffy = "=0.10.1" +taffy = "=0.12.2" thiserror.workspace = true gpui_util.workspace = true hdrhistogram = { workspace = true, optional = true } @@ -197,6 +195,10 @@ path = "examples/input.rs" name = "on_window_close_quit" path = "examples/on_window_close_quit.rs" +[[example]] +name = "window_movable" +path = "examples/window_movable.rs" + [[example]] name = "opacity" path = "examples/opacity.rs" @@ -209,6 +211,11 @@ path = "examples/pattern.rs" name = "set_menus" path = "examples/set_menus.rs" +[[example]] +name = "system_notifications" +path = "examples/system_notifications.rs" + + [[example]] name = "shadow" path = "examples/shadow.rs" @@ -256,3 +263,7 @@ path = "examples/mouse_pressure.rs" [[example]] name = "a11y" path = "examples/a11y.rs" + +[[example]] +name = "view_example" +path = "examples/view_example/view_example_main.rs" diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index 7fc75e0f5bad7f..dab1c302c9135a 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -54,6 +54,7 @@ cargo run -p gpui --example hello_world - `move_entity_between_windows` shows moving an entity between windows. - `on_window_close_quit` demonstrates quitting when a window closes. - `set_menus` shows application menu setup. +- `system_notifications` demonstrates posting, replacing, dismissing, and responding to operating-system notifications. - `window` demonstrates creating normal, dialog, popup, and floating windows. - `window_positioning` demonstrates window bounds and placement. - `window_shadow` demonstrates window shadow styling. diff --git a/crates/gpui/examples/grid_layout.rs b/crates/gpui/examples/grid_layout.rs index 650a3e37bbc2f0..9c6d723eba0de2 100644 --- a/crates/gpui/examples/grid_layout.rs +++ b/crates/gpui/examples/grid_layout.rs @@ -1,68 +1,63 @@ #![cfg_attr(target_family = "wasm", no_main)] use gpui::{ - App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, + App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, container_query, div, + prelude::*, px, rgb, size, }; use gpui_platform::application; // https://en.wikipedia.org/wiki/Holy_grail_(web_design) +// +// Resize the window: the layout is chosen by `container_query` based on the +// measured size of the container, collapsing to a single stacked column when +// it becomes too narrow for the three-column grid. struct HolyGrailExample {} impl Render for HolyGrailExample { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let block = |color: Hsla| { - div() - .size_full() - .bg(color) - .border_1() - .border_dashed() - .rounded_md() - .border_color(gpui::white()) - .items_center() - }; + container_query(|container_size, _window, _cx| { + let block = |color: Hsla| { + div() + .size_full() + .bg(color) + .border_1() + .border_dashed() + .rounded_md() + .border_color(gpui::white()) + .items_center() + }; - div() - .gap_1() - .grid() - .bg(rgb(0x505050)) - .size(px(500.0)) - .shadow_lg() - .border_1() - .size_full() - .grid_cols(5) - .grid_rows(5) - .child( - block(gpui::white()) - .row_span(1) - .col_span_full() - .child("Header"), - ) - .child( - block(gpui::red()) - .col_span(1) - .h_56() - .child("Table of contents"), - ) - .child( - block(gpui::green()) - .col_span(3) - .row_span(3) - .child("Content"), - ) - .child( - block(gpui::blue()) - .col_span(1) - .row_span(3) - .child("AD :(") - .text_color(gpui::white()), - ) - .child( - block(gpui::black()) - .row_span(1) - .col_span_full() - .text_color(gpui::white()) - .child("Footer"), - ) + let header = block(gpui::white()).child(format!("Header — {}", container_size.width)); + let table_of_contents = block(gpui::red()).child("Table of contents"); + let content = block(gpui::green()).child("Content"); + let ad = block(gpui::blue()).child("AD :(").text_color(gpui::white()); + let footer = block(gpui::black()) + .text_color(gpui::white()) + .child("Footer"); + + let container = div().gap_1().bg(rgb(0x505050)).shadow_lg().size_full(); + + if container_size.width < px(400.) { + container + .flex() + .flex_col() + .child(header.h_12().flex_none()) + .child(table_of_contents.h_20().flex_none()) + .child(content.flex_1()) + .child(ad.h_20().flex_none()) + .child(footer.h_12().flex_none()) + } else { + container + .grid() + .grid_cols(5) + .grid_rows(5) + .child(header.row_span(1).col_span_full()) + .child(table_of_contents.col_span(1).h_56()) + .child(content.col_span(3).row_span(3)) + .child(ad.col_span(1).row_span(3)) + .child(footer.row_span(1).col_span_full()) + } + }) } } diff --git a/crates/gpui/examples/image/exif-orientation-rotate-180.jpg b/crates/gpui/examples/image/exif-orientation-rotate-180.jpg new file mode 100644 index 00000000000000..9c095652a1697a Binary files /dev/null and b/crates/gpui/examples/image/exif-orientation-rotate-180.jpg differ diff --git a/crates/gpui/examples/image/image.rs b/crates/gpui/examples/image/image.rs index 45fce26d046c17..688f881bc6df94 100644 --- a/crates/gpui/examples/image/image.rs +++ b/crates/gpui/examples/image/image.rs @@ -100,7 +100,7 @@ impl Render for ImageShowcase { .items_center() .gap_8() .child(ImageContainer::new( - "Image loaded from a local file", + "Image loaded from a local file with EXIF orientation", self.local_resource.clone(), )) .child(ImageContainer::new( @@ -202,7 +202,9 @@ fn run_example() { cx.open_window(window_options, |_, cx| { cx.new(|_| ImageShowcase { // Relative path to your root project path - local_resource: manifest_dir.join("examples/image/app-icon.png").into(), + local_resource: manifest_dir + .join("examples/image/exif-orientation-rotate-180.jpg") + .into(), remote_resource: "https://picsum.photos/800/400".into(), asset_resource: "image/color.svg".into(), }) diff --git a/crates/gpui/examples/system_notifications.rs b/crates/gpui/examples/system_notifications.rs new file mode 100644 index 00000000000000..70071dc96f46d6 --- /dev/null +++ b/crates/gpui/examples/system_notifications.rs @@ -0,0 +1,149 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +//! Demonstrates posting, replacing, dismissing, and responding to system notifications. + +use gpui::{ + App, Bounds, Context, Div, SharedString, Stateful, SystemNotification, + SystemNotificationAction, SystemNotificationResponse, Window, WindowBounds, WindowOptions, div, + prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +const NOTIFICATION_TAG: &str = "gpui-system-notification-example"; + +struct SystemNotificationExample { + revision: usize, + status: SharedString, +} + +impl Render for SystemNotificationExample { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .size_full() + .gap_4() + .p_8() + .bg(rgb(0x18181b)) + .text_color(rgb(0xf4f4f5)) + .child(div().text_2xl().child("GPUI system notifications")) + .child(div().text_sm().text_color(rgb(0xa1a1aa)).child( + "Post repeatedly to replace the notification with the same tag. Click the \ + notification body or an action button to send a response back to GPUI.", + )) + .child( + div() + .flex() + .gap_3() + .child(button("show", "Show or replace").on_click(cx.listener( + |this, _, _, cx| { + this.revision += 1; + let revision = this.revision; + cx.show_system_notification(SystemNotification { + tag: NOTIFICATION_TAG.into(), + title: format!("Example notification {revision}").into(), + body: "This notification was posted by the GPUI example.".into(), + actions: vec![ + SystemNotificationAction { + id: "open".into(), + label: "Open".into(), + }, + SystemNotificationAction { + id: "snooze".into(), + label: "Snooze".into(), + }, + ], + }); + this.status = format!("Posted notification revision {revision}").into(); + cx.notify(); + }, + ))) + .child( + button("dismiss", "Dismiss").on_click(cx.listener(|this, _, _, cx| { + cx.dismiss_system_notification(NOTIFICATION_TAG); + this.status = "Dismissed the notification".into(); + cx.notify(); + })), + ), + ) + .child( + div() + .mt_2() + .p_4() + .rounded_md() + .bg(rgb(0x27272a)) + .child(self.status.clone()), + ) + .when(cfg!(target_os = "macos"), |this| { + this.child(div().mt_2().text_xs().text_color(rgb(0x71717a)).child( + "macOS only delivers notifications when this example runs from an app bundle.", + )) + }) + } +} + +fn button(id: &'static str, label: &'static str) -> Stateful
{ + div() + .id(id) + .px_4() + .py_2() + .rounded_md() + .bg(rgb(0x3f3f46)) + .hover(|style| style.bg(rgb(0x52525b))) + .active(|style| style.bg(rgb(0x71717a))) + .cursor_pointer() + .child(label) +} + +fn run_example() { + application().run(|cx: &mut App| { + cx.set_app_identity("dev.zed.gpui.system-notifications", "GPUI Notifications"); + + let view = cx.new(|_| SystemNotificationExample { + revision: 0, + status: "No notification posted yet".into(), + }); + cx.on_system_notification_response({ + let view = view.clone(); + move |response, cx| { + let SystemNotificationResponse { tag, action_id } = response; + view.update(cx, |this, cx| { + this.status = match action_id { + Some(action_id) => { + format!("Received action '{action_id}' for tag '{tag}'").into() + } + None => format!("Notification body clicked for tag '{tag}'").into(), + }; + cx.notify(); + }); + } + }); + + let bounds = Bounds::centered(None, size(px(560.), px(360.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some("System Notifications Example".into()), + ..Default::default() + }), + ..Default::default() + }, + move |_, _| view, + ) + .expect("failed to open system notifications example window"); + 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/view_example/example_editor.rs b/crates/gpui/examples/view_example/example_editor.rs new file mode 100644 index 00000000000000..2064d7cacef29c --- /dev/null +++ b/crates/gpui/examples/view_example/example_editor.rs @@ -0,0 +1,549 @@ +//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard +//! handling, and the specialized text-shaping renderer. The *text itself* lives +//! in a shared `Entity` it's handed at construction, so the value is +//! readable/writable from outside while the editing machinery stays in here. +//! +//! This is the piece that proves the point: a text input is genuinely +//! complicated, and `View` lets all of that complexity live in one entity that +//! anything can embed. + +use std::ops::Range; +use std::time::Duration; + +use gpui::{ + App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable, + InteractiveElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Subscription, Task, + TextRun, UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size, +}; +use unicode_segmentation::*; + +use crate::{Backspace, Delete, End, Home, Left, Right}; + +pub struct Editor { + pub value: Entity, + pub focus_handle: FocusHandle, + pub cursor: usize, + pub cursor_visible: bool, + _blink_task: Task<()>, + _subscriptions: Vec, +} + +impl Editor { + /// An editor that owns its own string internally, seeded with `text`. + /// Nothing to allocate or wire up at the call site. + pub fn new(text: impl Into, window: &mut Window, cx: &mut Context) -> Self { + let value = cx.new(|_| text.into()); + Self::over(value, window, cx) + } + + /// An editor over a string *you* own, so the value is shared in and out. + pub fn over(value: Entity, window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + + let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| { + this.start_blink(cx); + }); + let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| { + this.stop_blink(cx); + }); + + // The value is shared: anything can write it while we hold a cursor into + // it. Observe it so external writes (a) clamp the cursor back onto a char + // boundary before the next IME round-trip can slice out of bounds, and + // (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache + // is keyed on *our* notify, not the value's. + let value_sub = cx.observe(&value, |this, value, cx| { + let content = value.read(cx); + let mut cursor = this.cursor.min(content.len()); + while cursor > 0 && !content.is_char_boundary(cursor) { + cursor -= 1; + } + this.cursor = cursor; + cx.notify(); + }); + + Self { + value, + focus_handle, + cursor: 0, + cursor_visible: false, + _blink_task: Task::ready(()), + _subscriptions: vec![focus_sub, blur_sub, value_sub], + } + } + + /// The current text. Read this from anywhere to get the value out. + pub fn text(&self, cx: &App) -> String { + self.value.read(cx).clone() + } + + fn start_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + fn stop_blink(&mut self, cx: &mut Context) { + self.cursor_visible = false; + self._blink_task = Task::ready(()); + cx.notify(); + } + + fn spawn_blink_task(cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + let result = this.update(cx, |editor, cx| { + editor.cursor_visible = !editor.cursor_visible; + cx.notify(); + }); + if result.is_err() { + break; + } + } + }) + } + + fn reset_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + self.cursor = previous_boundary(&content, self.cursor); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + self.cursor = next_boundary(&content, self.cursor); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + self.cursor = 0; + self.reset_blink(cx); + cx.notify(); + } + + pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.cursor = self.text(cx).len(); + self.reset_blink(cx); + cx.notify(); + } + + pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + let prev = previous_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(prev..cursor); + cx.notify(); + }); + self.cursor = prev; + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + let next = next_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(cursor..next); + cx.notify(); + }); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn insert_newline(&mut self, cx: &mut Context) { + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.insert(cursor, '\n'); + cx.notify(); + }); + self.cursor += 1; + self.reset_blink(cx); + cx.notify(); + } +} + +fn previous_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .rev() + .find_map(|(idx, _)| (idx < offset).then_some(idx)) + .unwrap_or(0) +} + +fn next_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .find_map(|(idx, _)| (idx > offset).then_some(idx)) + .unwrap_or(content.len()) +} + +fn offset_from_utf16(content: &str, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + for ch in content.chars() { + if utf16_count >= offset { + break; + } + utf16_count += ch.len_utf16(); + utf8_offset += ch.len_utf8(); + } + utf8_offset +} + +fn offset_to_utf16(content: &str, offset: usize) -> usize { + let mut utf16_offset = 0; + let mut utf8_count = 0; + for ch in content.chars() { + if utf8_count >= offset { + break; + } + utf8_count += ch.len_utf8(); + utf16_offset += ch.len_utf16(); + } + utf16_offset +} + +fn range_to_utf16(content: &str, range: &Range) -> Range { + offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end) +} + +fn range_from_utf16(content: &str, range_utf16: &Range) -> Range { + offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, range_utf16.end) +} + +impl Focusable for Editor { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EntityInputHandler for Editor { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + let range = range_from_utf16(&content, &range_utf16); + actual_range.replace(range_to_utf16(&content, &range)); + Some(content[range].to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + let utf16_cursor = offset_to_utf16(&content, self.cursor); + Some(UTF16Selection { + range: utf16_cursor..utf16_cursor, + reversed: false, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let content = self.text(cx); + let range = range_utf16 + .as_ref() + .map(|r| range_from_utf16(&content, r)) + .unwrap_or(self.cursor..self.cursor); + + let new_content = content[..range.start].to_owned() + new_text + &content[range.end..]; + self.cursor = range.start + new_text.len(); + self.value.update(cx, |s, cx| { + *s = new_content; + cx.notify(); + }); + self.reset_blink(cx); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + self.replace_text_in_range(range_utf16, new_text, window, cx); + } + + fn bounds_for_range( + &mut self, + _range_utf16: Range, + _bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } +} + +impl gpui::Render for Editor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + EditorText { + editor: cx.entity(), + } + } +} + +// --------------------------------------------------------------------------- +// EditorText — the specialized renderer: shapes the text and paints the cursor. +// --------------------------------------------------------------------------- + +struct EditorText { + editor: Entity, +} + +struct EditorTextPrepaint { + lines: Vec, + cursor: Option, +} + +impl IntoElement for EditorText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EditorText { + type RequestLayoutState = (); + type PrepaintState = EditorTextPrepaint; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let editor = self.editor.read(cx); + let content = editor.value.read(cx); + let line_count = content.split('\n').count().max(1); + let line_height = window.line_height(); + let mut style = gpui::Style::default(); + style.size.width = relative(1.).into(); + style.size.height = (line_height * line_count as f32).into(); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let editor = self.editor.read(cx); + let content = editor.value.read(cx).clone(); + let cursor_offset = editor.cursor; + let cursor_visible = editor.cursor_visible; + let is_focused = editor.focus_handle.is_focused(window); + + let style = window.text_style(); + let text_color = style.color; + let font_size = style.font_size.to_pixels(window.rem_size()); + let line_height = window.line_height(); + + let is_placeholder = content.is_empty(); + + let lines: Vec = if is_placeholder { + let placeholder: SharedString = "Type here...".into(); + let run = TextRun { + len: placeholder.len(), + font: style.font(), + color: hsla(0., 0., 0.5, 0.5), + background_color: None, + underline: None, + strikethrough: None, + }; + vec![ + window + .text_system() + .shape_line(placeholder, font_size, &[run], None), + ] + } else { + content + .split('\n') + .map(|line_str| { + let text: SharedString = SharedString::from(line_str.to_string()); + let run = TextRun { + len: text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(text, font_size, &[run], None) + }) + .collect() + }; + + let cursor = if is_focused && cursor_visible && !is_placeholder { + let (cursor_line, offset_in_line) = cursor_line_and_offset(&content, cursor_offset); + let cursor_line = cursor_line.min(lines.len().saturating_sub(1)); + let cursor_x = lines[cursor_line].x_for_index(offset_in_line); + Some(fill( + Bounds::new( + point( + bounds.left() + cursor_x, + bounds.top() + line_height * cursor_line as f32, + ), + size(px(1.5), line_height), + ), + text_color, + )) + } else if is_focused && cursor_visible && is_placeholder { + Some(fill( + Bounds::new( + point(bounds.left(), bounds.top()), + size(px(1.5), line_height), + ), + text_color, + )) + } else { + None + }; + + EditorTextPrepaint { lines, cursor } + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.editor.read(cx).focus_handle.clone(); + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.editor.clone()), + cx, + ); + + let line_height = window.line_height(); + for (i, line) in prepaint.lines.iter().enumerate() { + let origin = point(bounds.left(), bounds.top() + line_height * i as f32); + line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx) + .unwrap(); + } + + if let Some(cursor) = prepaint.cursor.take() { + window.paint_quad(cursor); + } + } +} + +fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) { + let mut line_index = 0; + let mut line_start = 0; + for (i, ch) in content.char_indices() { + if i >= cursor { + break; + } + if ch == '\n' { + line_index += 1; + line_start = i + 1; + } + } + (line_index, cursor - line_start) +} + +pub fn standard_actions(editor: Entity) -> impl FnOnce(E) -> E { + move |element| { + element + .on_action({ + let editor = editor.clone(); + move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Backspace, window, cx| { + editor.update(cx, |e, cx| e.backspace(a, window, cx)) + } + }) + .on_action(move |a: &Delete, window, cx| { + editor.update(cx, |e, cx| e.delete(a, window, cx)) + }) + } +} diff --git a/crates/gpui/examples/view_example/example_input.rs b/crates/gpui/examples/view_example/example_input.rs new file mode 100644 index 00000000000000..25d74013deb757 --- /dev/null +++ b/crates/gpui/examples/view_example/example_input.rs @@ -0,0 +1,121 @@ +//! `Input` — a single-line text input. The shaping layer over `Editor`. +//! +//! Construct it two ways, depending on how much state you want to own: +//! * `Input::new(value: Entity)` — you hold just the string; the input +//! allocates the `Editor` internally via `use_state`. Value readable, cursor hidden. +//! * `Input::editor(editor: Entity)` — you hold the editor; cursor/selection +//! are now yours to read and drive too. +//! +//! Either way the chrome is identical. Because the string (or editor) is the +//! input's *identity*, the internal `use_state(Editor)` is collision-safe across +//! any number of inputs. + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement, + Window, div, hsla, point, prelude::*, px, white, +}; + +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct Input { + source: Source, + width: Option, + color: Option, +} + +impl Input { + /// Backed by a bare string; the editor is allocated internally. + pub fn new(value: Entity) -> Self { + Self { + source: Source::Value(value), + width: None, + color: None, + } + } + + /// Backed by an editor you own (so you can read/drive its cursor). + pub fn editor(editor: Entity) -> Self { + Self { + source: Source::Editor(editor), + width: None, + color: None, + } + } + + pub fn width(mut self, width: Pixels) -> Self { + self.width = Some(width); + self + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for Input { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + // Get the editor: use the one we were handed, or allocate it under our + // own (string-derived) identity so it persists and never collides. + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let box_width = self.width.unwrap_or(px(300.)); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("input") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + .w(box_width) + .h(px(36.)) + .px(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .flex() + .items_center() + .line_height(px(20.)) + .text_size(px(14.)) + .text_color(text_color) + .child(editor.cached(StyleRefinement::default().size_full())) + } +} diff --git a/crates/gpui/examples/view_example/example_tests.rs b/crates/gpui/examples/view_example/example_tests.rs new file mode 100644 index 00000000000000..a3edae8cda1f6d --- /dev/null +++ b/crates/gpui/examples/view_example/example_tests.rs @@ -0,0 +1,131 @@ +//! Tests for the input composition. Require the `test-support` feature: +//! +//! ```sh +//! cargo test -p gpui --example view_example --features test-support +//! ``` + +#[cfg(test)] +mod tests { + use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*}; + + use crate::example_editor::Editor; + use crate::example_input::Input; + use crate::{Backspace, Delete, End, Home, Left, Right}; + + /// Two inputs, each backed by an editor we own (so the test can focus and + /// read them). Proves data flows through the shared `String` and that + /// sibling inputs stay isolated. + struct Harness { + a: Entity, + b: Entity, + } + + impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div() + .child(Input::editor(self.a.clone())) + .child(Input::editor(self.b.clone())) + } + } + + fn bind_keys(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + ]); + }); + } + + fn setup( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + Entity, + &mut gpui::VisualTestContext, + ) { + bind_keys(cx); + + let (harness, cx) = cx.add_window_view(|window, cx| { + let a_value = cx.new(|_| String::new()); + let b_value = cx.new(|_| String::new()); + let a = cx.new(|cx| Editor::over(a_value, window, cx)); + let b = cx.new(|cx| Editor::over(b_value, window, cx)); + Harness { a, b } + }); + + let a = cx.read_entity(&harness, |h, _| h.a.clone()); + let b = cx.read_entity(&harness, |h, _| h.b.clone()); + let a_value = cx.read_entity(&a, |e, _| e.value.clone()); + let b_value = cx.read_entity(&b, |e, _| e.value.clone()); + + // Focus the first input's editor. + cx.update(|window, cx| { + let focus_handle = a.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }); + + (a, a_value, b_value, cx) + } + + #[gpui::test] + fn typing_updates_the_shared_string(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("hello"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello")); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5)); + } + + #[gpui::test] + fn sibling_inputs_are_isolated(cx: &mut TestAppContext) { + let (_editor, a_value, b_value, cx) = setup(cx); + + cx.simulate_input("x"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "x")); + cx.read_entity(&b_value, |value, _| { + assert_eq!(value, "", "typing in input A must not touch input B") + }); + } + + #[gpui::test] + fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("hello"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5)); + + // Write the shared value from outside the editor. The old cursor (5) + // now points into the middle of a multi-byte character; the editor's + // observation must clamp it back onto a boundary. + cx.update(|_, cx| { + a_value.update(cx, |value, cx| { + *value = "日本".to_string(); + cx.notify(); + }) + }); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "日本")); + cx.read_entity(&editor, |editor, _| { + assert_eq!(editor.cursor, 3, "cursor must clamp to a char boundary"); + }); + } + + #[gpui::test] + fn arrows_move_the_cursor(cx: &mut TestAppContext) { + let (editor, _a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("abc"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3)); + + cx.simulate_keystrokes("left left"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1)); + } +} diff --git a/crates/gpui/examples/view_example/example_text_area.rs b/crates/gpui/examples/view_example/example_text_area.rs new file mode 100644 index 00000000000000..07640b93294677 --- /dev/null +++ b/crates/gpui/examples/view_example/example_text_area.rs @@ -0,0 +1,118 @@ +//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome, +//! and `Enter` inserts a newline instead of being ignored. Constructible from a +//! string or an editor, exactly like [`Input`](crate::example_input::Input). + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div, + hsla, point, prelude::*, px, white, +}; + +use crate::Enter; +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct TextArea { + source: Source, + rows: usize, + color: Option, +} + +impl TextArea { + pub fn new(value: Entity, rows: usize) -> Self { + Self { + source: Source::Value(value), + rows, + color: None, + } + } + + pub fn editor(editor: Entity, rows: usize) -> Self { + Self { + source: Source::Editor(editor), + rows, + color: None, + } + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for TextArea { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let row_height = px(20.); + let box_height = row_height * self.rows as f32 + px(16.); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("text-area") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + // Enter is the one binding that differs from a single-line input. + .on_action({ + let editor = editor.clone(); + move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx)) + }) + .w(px(400.)) + .h(box_height) + .p(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .line_height(row_height) + .text_size(px(14.)) + .text_color(text_color) + // The cache style is computed from the `rows` prop: change `rows` and + // the editor's cached bounds change, busting its cache and re-laying + // out the text. (`Input` just uses `size_full()` — nothing to vary.) + .child( + editor.cached( + StyleRefinement::default() + .w_full() + .h(row_height * self.rows as f32), + ), + ) + } +} diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs new file mode 100644 index 00000000000000..0eac8494ffc016 --- /dev/null +++ b/crates/gpui/examples/view_example/view_example_main.rs @@ -0,0 +1,173 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +//! View example — composing a text input from the `View` primitives. +//! +//! The whole point: a text input is deceptively complicated, and `View` makes it +//! easy to compose one. Three pieces, each shown in its own section: +//! +//! * `Editor` — the workhorse entity: cursor, blink, focus, keyboard, and a +//! specialized text renderer. All the hard parts live here. +//! * `String` — the data plane. `editor.text(cx)` / `value.read(cx)` get it out. +//! * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows +//! the editor internally) OR an `Editor` (so you can read the cursor). +//! +//! Run: `cargo run -p gpui --example view_example` + +mod example_editor; +mod example_input; +mod example_text_area; + +#[cfg(test)] +mod example_tests; + +use example_editor::Editor; +use example_input::Input; +use example_text_area::TextArea; + +use gpui::{ + App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window, + WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +actions!( + view_example, + [Backspace, Delete, Left, Right, Home, End, Enter, Quit] +); + +/// A tiny stateless view that reads an editor's cursor and is composed *beside* +/// the thing editing it — two views over one entity, zero wiring. +#[derive(IntoElement)] +struct CursorReadout { + editor: Entity, +} + +impl CursorReadout { + fn new(editor: Entity) -> Self { + Self { editor } + } +} + +impl gpui::RenderOnce for CursorReadout { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let cursor = self.editor.read(cx).cursor; + div() + .text_sm() + .text_color(hsla(0., 0., 0.45, 1.)) + .child(SharedString::from(format!("cursor @ {cursor}"))) + } +} + +struct ViewExample; + +impl ViewExample { + fn new() -> Self { + Self + } +} + +impl Render for ViewExample { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // The data plane: plain strings, allocated at the top by the hook. + let name = window.use_state(cx, |_, _| String::new()); + let email = window.use_state(cx, |_, _| String::from("me@example.com")); + let bio = window.use_state(cx, |_, _| String::new()); + // Editors that own their own string internally — no extra wiring up top. + let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx)); + let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx)); + + div() + .flex() + .flex_col() + .size_full() + .bg(rgb(0xf0f0f0)) + .p(px(24.)) + .gap(px(24.)) + .child( + section("Inputs — from a String (cursor stays internal)") + .child(Input::new(name).width(px(320.))) + .child( + Input::new(email) + .width(px(320.)) + .color(hsla(0., 0., 0.3, 1.)), + ), + ) + .child( + section("Input — from an Editor (read its cursor beside it)").child( + div() + .flex() + .items_center() + .gap(px(12.)) + .child(Input::editor(owned.clone()).width(px(320.))) + .child(CursorReadout::new(owned)), + ), + ) + .child( + section("Text areas — from a String, or from an Editor") + .child(TextArea::new(bio, 3)) + .child( + div() + .flex() + .items_start() + .gap(px(12.)) + .child(TextArea::editor(notes.clone(), 3).color(hsla( + 250. / 360., + 0.7, + 0.4, + 1., + ))) + .child(CursorReadout::new(notes)), + ), + ) + } +} + +/// A labeled vertical section. +fn section(title: &str) -> Div { + div().flex().flex_col().gap(px(8.)).child( + div() + .text_sm() + .text_color(hsla(0., 0., 0.3, 1.)) + .child(SharedString::from(title.to_string())), + ) +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx); + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + KeyBinding::new("enter", Enter, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| ViewExample::new()), + ) + .unwrap(); + + cx.on_action(|_: &Quit, cx| cx.quit()); + 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/window_movable.rs b/crates/gpui/examples/window_movable.rs new file mode 100644 index 00000000000000..587a2dbfea55cc --- /dev/null +++ b/crates/gpui/examples/window_movable.rs @@ -0,0 +1,125 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, FocusHandle, Window, WindowBounds, WindowOptions, div, prelude::*, px, + rgb, size, +}; +use gpui::{SharedString, TitlebarOptions}; +use gpui_platform::application; + +struct ExampleWindow { + label: SharedString, + focus_handle: FocusHandle, +} + +impl Render for ExampleWindow { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .flex() + .flex_col() + .gap_3() + .bg(rgb(0x2e2e2e)) + .size_full() + .justify_center() + .items_center() + .p_8() + .text_lg() + .text_color(rgb(0xffffff)) + .child(self.label.clone()) + .child( + div() + .text_sm() + .text_color(rgb(0xb0b0b0)) + .child("Try to drag the titlebar, and check the Window menu."), + ) + } +} + +fn open_test_window( + cx: &mut App, + bounds: Bounds, + label: &str, + is_movable: bool, + appears_transparent: bool, + app_owns_titlebar_drag: bool, +) { + let label = SharedString::from(format!( + "{label}\nis_movable: {is_movable}\n\ + appears_transparent: {appears_transparent}\n\ + app_owns_titlebar_drag: {app_owns_titlebar_drag}" + )); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + is_movable, + app_owns_titlebar_drag, + titlebar: Some(TitlebarOptions { + title: Some(label.clone()), + appears_transparent, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| { + cx.new(|cx| { + let focus_handle = cx.focus_handle(); + focus_handle.focus(window, cx); + ExampleWindow { + label, + focus_handle, + } + }) + }, + ) + .unwrap(); +} + +fn run_example() { + application().run(|cx: &mut App| { + let window_size = size(px(420.), px(280.0)); + let base = Bounds::centered(None, window_size, cx); + + // (label, is_movable, appears_transparent, app_owns_titlebar_drag, col, row) + let windows = [ + ("Native titlebar, movable", true, false, false, 0.0, 0.0), + ( + "Native titlebar, NOT movable", + false, + false, + false, + 1.0, + 0.0, + ), + ("Custom titlebar, movable", true, true, false, 0.0, 1.0), + ("Custom titlebar, NOT movable", false, true, false, 1.0, 1.0), + ]; + + for (label, is_movable, appears_transparent, app_owns_titlebar_drag, col, row) in windows { + let mut bounds = base; + bounds.origin.x += window_size.width * col; + bounds.origin.y += window_size.height * row; + open_test_window( + cx, + bounds, + label, + is_movable, + appears_transparent, + app_owns_titlebar_drag, + ); + } + }); +} + +#[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/src/app.rs b/crates/gpui/src/app.rs index e6ca25ecae075e..857b792dd14931 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -50,9 +50,9 @@ use crate::{ PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, - SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextRenderingMode, TextSystem, - ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, - WindowInvalidator, + SharedString, SubscriberSet, Subscription, SvgRenderer, SystemNotification, + SystemNotificationResponse, Task, TextRenderingMode, TextSystem, ThermalState, Window, + WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -143,6 +143,31 @@ impl Drop for AppRefMut<'_> { /// You won't interact with this type much outside of initial configuration and startup. pub struct Application(Rc); +/// A strong handle to an [`Application`] started with [`Application::run_embedded`]. +/// +/// Dropping this handle releases the app, so an embedder must hold it for as long as the +/// app should run. While held, it is the embedder's entry point back into GPUI each time +/// the external run loop gives it control. +pub struct ApplicationHandle { + app: Rc, +} + +impl ApplicationHandle { + /// Invoke `f` with the app context. Must not be called re-entrantly from code that + /// is already inside an update; the app state is a `RefCell` and will panic on a + /// double borrow. + pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { + let cx = &mut *self.app.borrow_mut(); + f(cx) + } + + /// An [`AsyncApp`] for use across await points. It holds the app weakly; keeping the + /// app alive remains this handle's job. + pub fn to_async(&self) -> AsyncApp { + self.update(|cx| cx.to_async()) + } +} + /// Represents an application before it is fully launched. Once your app is /// configured, you'll start the app with `App::run`. impl Application { @@ -209,6 +234,28 @@ impl Application { })); } + /// Start the application for an embedder that drives the run loop itself. + /// + /// On ordinary platforms `Platform::run` blocks for the lifetime of the app, and the + /// app state is kept alive by [`Application::run`]'s stack frame. Embedded platforms — + /// where the run loop belongs to someone else, e.g. GPUI compiled into a Wasm guest, + /// or a GPUI view hosted inside a foreign native application — implement + /// `Platform::run` to invoke the launch callback and return immediately. This method + /// supports that shape: it returns an [`ApplicationHandle`] that keeps the app alive + /// and lets the embedder re-enter it whenever the external run loop yields control. + pub fn run_embedded(self, on_finish_launching: F) -> ApplicationHandle + where + F: 'static + FnOnce(&mut App), + { + let this = self.0.clone(); + let platform = self.0.borrow().platform.clone(); + platform.run(Box::new(move || { + let cx = &mut *this.borrow_mut(); + on_finish_launching(cx); + })); + ApplicationHandle { app: self.0 } + } + /// Register a handler to be invoked when the platform instructs the application /// to open one or more URLs. pub fn on_open_urls(&self, mut callback: F) -> &Self @@ -702,6 +749,7 @@ pub struct App { pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, pub(crate) cursor_hide_mode: CursorHideMode, + pub(crate) reduce_motion: bool, /// Whether the app was created by [`Application::new_inaccessible`]. No /// accesskit APIs will be called when this flag is set. pub(crate) accessibility_force_disabled: bool, @@ -794,6 +842,7 @@ impl App { quit_mode: QuitMode::default(), quitting: false, cursor_hide_mode: CursorHideMode::default(), + reduce_motion: false, accessibility_force_disabled: false, #[cfg(any(test, feature = "test-support", debug_assertions))] @@ -956,6 +1005,21 @@ impl App { self.platform.is_cursor_visible() } + /// Returns whether non-essential animations (e.g. loading spinners) should + /// be rendered in a static state instead of animating. + pub fn reduce_motion(&self) -> bool { + self.reduce_motion + } + + /// Sets whether non-essential animations (e.g. loading spinners) should be + /// rendered in a static state instead of animating. + pub fn set_reduce_motion(&mut self, reduce_motion: bool) { + if self.reduce_motion != reduce_motion { + self.reduce_motion = reduce_motion; + self.refresh_windows(); + } + } + /// Schedules all windows in the application to be redrawn. This can be called /// multiple times in an update cycle and still result in a single redraw. pub fn refresh_windows(&mut self) { @@ -1355,6 +1419,50 @@ impl App { self.platform.register_url_scheme(scheme) } + /// Sets the application's process-wide identity and user-visible name. + /// + /// The identifier is used for platform identity mechanisms such as the + /// Windows AppUserModelID. The name is used wherever the operating system + /// presents the application to the user. Call this once, early in startup, + /// before opening windows or posting notifications. + pub fn set_app_identity(&self, identifier: &str, name: &str) { + self.platform.set_app_identity(identifier, name); + } + + /// Posts a notification to the operating system's notification center. + /// + /// Posting a notification whose [`SystemNotification::tag`] matches an + /// earlier one replaces that notification where the platform supports it. + /// No-op on platforms without notification support, or when delivery is + /// unavailable (e.g. authorization was denied). + pub fn show_system_notification(&self, notification: SystemNotification) { + self.platform.show_system_notification(notification); + } + + /// Removes the delivered or pending notification with this tag. + /// + /// Best-effort: some platforms cannot retract a notification once shown, + /// in which case it ages out of the notification center on its own. + pub fn dismiss_system_notification(&self, tag: &str) { + self.platform.dismiss_system_notification(tag); + } + + /// Registers the handler invoked when the user activates a system + /// notification, either by clicking its body or one of its action + /// buttons. Subsequent registrations replace the handler. + pub fn on_system_notification_response(&self, mut callback: F) + where + F: 'static + FnMut(SystemNotificationResponse, &mut App), + { + let this = self.this.clone(); + self.platform + .on_system_notification_response(Box::new(move |response| { + if let Some(app) = this.upgrade() { + callback(response, &mut app.borrow_mut()); + } + })); + } + /// Returns the full pathname of the current app bundle. /// /// Returns an error if the app is not being run from a bundle. diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 9e32c5dc2d4520..582e1ad8400df5 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -3,9 +3,10 @@ use crate::{ BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable, Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, - TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, - WindowHandle, WindowOptions, app::GpuiMode, window::ElementArenaScope, + Pixels, Platform, Point, Render, Result, SharedString, Size, SystemNotification, + SystemNotificationResponse, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, + TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, + app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -371,6 +372,32 @@ impl TestAppContext { self.test_platform.opened_url.borrow().clone() } + /// Returns the application identity configured during this test. + pub fn app_identity(&self) -> Option<(SharedString, SharedString)> { + self.test_platform.app_identity() + } + + /// Returns all system notifications shown during this test, in order. + pub fn shown_system_notifications(&self) -> Vec { + self.test_platform.shown_system_notifications() + } + + /// Returns the system notifications currently delivered by the test platform. + pub fn delivered_system_notifications(&self) -> Vec { + self.test_platform.delivered_system_notifications() + } + + /// Returns the tags of all system notifications dismissed during this test, in order. + pub fn dismissed_system_notifications(&self) -> Vec { + self.test_platform.dismissed_system_notifications() + } + + /// Simulates the user activating a system notification. + pub fn simulate_system_notification_response(&self, response: SystemNotificationResponse) { + self.test_platform + .simulate_system_notification_response(response); + } + /// Simulates the user resizing the window to the new size. pub fn simulate_window_resize(&self, window_handle: AnyWindowHandle, size: Size) { self.test_window(window_handle).simulate_resize(size); @@ -1115,8 +1142,153 @@ impl AnyWindowHandle { #[cfg(test)] mod tests { - use crate::{PathPromptOptions, TestAppContext}; + use crate::{ + PathPromptOptions, SystemNotification, SystemNotificationAction, + SystemNotificationResponse, TestAppContext, + }; + use std::cell::RefCell; use std::path::PathBuf; + use std::rc::Rc; + + #[gpui::test] + async fn test_system_notifications_require_identity_and_replace_matching_tags( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + cx.show_system_notification(SystemNotification { + tag: "thread-1".into(), + title: "Task started".into(), + body: "Running tests".into(), + actions: Vec::new(), + }); + }); + assert!(cx.shown_system_notifications().is_empty()); + assert!(cx.delivered_system_notifications().is_empty()); + + cx.update(|cx| { + cx.set_app_identity("com.example.tasks", "Tasks"); + cx.show_system_notification(SystemNotification { + tag: "thread-1".into(), + title: "Task started".into(), + body: "Running tests".into(), + actions: Vec::new(), + }); + cx.show_system_notification(SystemNotification { + tag: "thread-1".into(), + title: "Task finished".into(), + body: "All tests passed".into(), + actions: vec![SystemNotificationAction { + id: "open".into(), + label: "Open".into(), + }], + }); + }); + + assert_eq!( + cx.app_identity(), + Some(("com.example.tasks".into(), "Tasks".into())) + ); + assert_eq!(cx.shown_system_notifications().len(), 2); + assert_eq!( + cx.delivered_system_notifications(), + [SystemNotification { + tag: "thread-1".into(), + title: "Task finished".into(), + body: "All tests passed".into(), + actions: vec![SystemNotificationAction { + id: "open".into(), + label: "Open".into(), + }], + }] + ); + + cx.update(|cx| cx.dismiss_system_notification("thread-1")); + assert!(cx.delivered_system_notifications().is_empty()); + assert_eq!(cx.dismissed_system_notifications(), ["thread-1"]); + } + + #[gpui::test] + async fn test_system_notification_body_and_action_responses(cx: &mut TestAppContext) { + let responses = Rc::new(RefCell::new(Vec::new())); + cx.update(|cx| { + cx.on_system_notification_response({ + let responses = responses.clone(); + move |response, _cx| responses.borrow_mut().push(response) + }); + }); + + cx.simulate_system_notification_response(SystemNotificationResponse { + tag: "thread-1".into(), + action_id: None, + }); + cx.simulate_system_notification_response(SystemNotificationResponse { + tag: "thread-1".into(), + action_id: Some("default".into()), + }); + + assert_eq!( + responses.borrow().as_slice(), + &[ + SystemNotificationResponse { + tag: "thread-1".into(), + action_id: None, + }, + SystemNotificationResponse { + tag: "thread-1".into(), + action_id: Some("default".into()), + }, + ] + ); + } + + #[gpui::test] + async fn test_system_notification_response_handler_can_be_replaced(cx: &mut TestAppContext) { + let first_responses = Rc::new(RefCell::new(Vec::new())); + let second_responses = Rc::new(RefCell::new(Vec::new())); + cx.update(|cx| { + cx.on_system_notification_response({ + let first_responses = first_responses.clone(); + move |response, _cx| first_responses.borrow_mut().push(response) + }); + cx.on_system_notification_response({ + let second_responses = second_responses.clone(); + move |response, _cx| second_responses.borrow_mut().push(response) + }); + }); + + let response = SystemNotificationResponse { + tag: "thread-1".into(), + action_id: None, + }; + cx.simulate_system_notification_response(response.clone()); + + assert!(first_responses.borrow().is_empty()); + assert_eq!(second_responses.borrow().as_slice(), &[response]); + } + + #[gpui::test] + async fn test_system_notification_response_handler_can_reenter_app(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_app_identity("com.example.tasks", "Tasks"); + cx.show_system_notification(SystemNotification { + tag: "thread-1".into(), + title: "Task finished".into(), + body: "All tests passed".into(), + actions: Vec::new(), + }); + cx.on_system_notification_response(|response, cx| { + cx.dismiss_system_notification(&response.tag); + }); + }); + + cx.simulate_system_notification_response(SystemNotificationResponse { + tag: "thread-1".into(), + action_id: None, + }); + + assert!(cx.delivered_system_notifications().is_empty()); + assert_eq!(cx.dismissed_system_notifications(), ["thread-1"]); + } #[gpui::test] async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) { diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index f212f246caab86..c817492b949a31 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -33,12 +33,12 @@ use crate::{ A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, - FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window, + FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window, util::FluentBuilder, window::with_element_arena, }; use derive_more::{Deref, DerefMut}; use std::{ - any::{Any, type_name}, + any::Any, fmt::{self, Debug, Display}, mem, panic, sync::Arc, @@ -208,116 +208,6 @@ pub trait ParentElement { } } -/// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro -/// for [`RenderOnce`] -#[doc(hidden)] -pub struct Component { - component: Option, - #[cfg(debug_assertions)] - source: &'static core::panic::Location<'static>, -} - -impl Component { - /// Create a new component from the given RenderOnce type. - #[track_caller] - pub fn new(component: C) -> Self { - Component { - component: Some(component), - #[cfg(debug_assertions)] - source: core::panic::Location::caller(), - } - } -} - -fn prepaint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.prepaint(window, cx); - }) -} - -fn paint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.paint(window, cx); - }) -} -impl Element for Component { - type RequestLayoutState = (AnyElement, &'static str); - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - #[cfg(debug_assertions)] - return Some(self.source); - - #[cfg(not(debug_assertions))] - return None; - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_id(ElementId::Name(type_name::().into()), |window| { - let mut element = self - .component - .take() - .unwrap() - .render(window, cx) - .into_any_element(); - - let layout_id = element.request_layout(window, cx); - (layout_id, (element, type_name::())) - }) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) { - prepaint_component(state, window, cx); - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - paint_component(state, window, cx); - } -} - -impl IntoElement for Component { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - /// A globally unique identifier for an element, used to track state across frames. #[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)] pub struct GlobalElementId(pub(crate) Arc<[ElementId]>); @@ -488,6 +378,24 @@ impl Drawable { self.element.write_a11y_info(&mut node); window.a11y.node_bounds.insert(node_id, bounds); pushed_a11y_node = window.a11y.nodes.push(node_id, node); + #[cfg(debug_assertions)] + if pushed_a11y_node { + let view = window + .a11y + .view_type_names + .get(&window.current_view()) + .copied(); + let source_location = self.element.source_location(); + window.a11y.nodes.record_node_info( + node_id, + crate::window::a11y::debug::NodeDebugInfo { + synthetic: false, + view, + element_id: global_id.0.last().map(|id| format!("{id:?}")), + source_location, + }, + ); + } } } } @@ -505,10 +413,24 @@ impl Drawable { if pushed_a11y_node { if let Some(global_id) = global_id.as_ref() { + #[cfg(debug_assertions)] + let creator = crate::window::a11y::debug::NodeCreator { + view: window + .a11y + .view_type_names + .get(&window.current_view()) + .copied(), + element_id: global_id.0.last().map(|id| format!("{id:?}")), + source_location: self.element.source_location(), + }; let mut builder = A11ySubtreeBuilder::new( global_id.accesskit_node_id(), &mut window.a11y.nodes, ); + #[cfg(debug_assertions)] + { + builder = builder.with_creator(creator); + } self.element .a11y_synthetic_children(&mut prepaint, &mut builder); } diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index 8a42c8bd492469..0a3c0c300bad60 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -2,7 +2,8 @@ use scheduler::Instant; use std::{rc::Rc, time::Duration}; use crate::{ - AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window, + AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + ParentElement, Window, }; pub use easing::*; @@ -47,6 +48,12 @@ impl Animation { } /// An extension trait for adding the animation wrapper to both Elements and Components +/// +/// Animations rendered through this trait automatically respect +/// [`App::reduce_motion`](crate::App::reduce_motion): when it is set, +/// the element is rendered in a static state (the end state for oneshot +/// animations, the start state for repeating ones) and no animation frames are +/// scheduled. pub trait AnimationExt { /// Render this component or element with an animation fn with_animation( @@ -95,6 +102,16 @@ pub struct AnimationElement { animator: Box E + 'static>, } +impl ParentElement for AnimationElement { + fn extend(&mut self, elements: impl IntoIterator) { + let Some(element) = &mut self.element else { + return; + }; + + element.extend(elements); + } +} + impl AnimationElement { /// Returns a new [`AnimationElement`] after applying the given function /// to the element being animated. @@ -141,25 +158,36 @@ impl Element for AnimationElement { start: Instant::now(), animation_ix: 0, }); - let animation_ix = state.animation_ix; - - let mut delta = state.start.elapsed().as_secs_f32() - / self.animations[animation_ix].duration.as_secs_f32(); - - let mut done = false; - if delta > 1.0 { - if self.animations[animation_ix].oneshot { - if animation_ix >= self.animations.len() - 1 { - done = true; + let (animation_ix, delta, done) = if cx.reduce_motion() { + let animation_ix = self.animations.len() - 1; + let delta = if self.animations[animation_ix].oneshot { + 1.0 + } else { + 0.0 + }; + (animation_ix, delta, true) + } else { + let animation_ix = state.animation_ix; + + let mut delta = state.start.elapsed().as_secs_f32() + / self.animations[animation_ix].duration.as_secs_f32(); + + let mut done = false; + if delta > 1.0 { + if self.animations[animation_ix].oneshot { + if animation_ix >= self.animations.len() - 1 { + done = true; + } else { + state.start = Instant::now(); + state.animation_ix += 1; + } + delta = 1.0; } else { - state.start = Instant::now(); - state.animation_ix += 1; + delta %= 1.0; } - delta = 1.0; - } else { - delta %= 1.0; } - } + (animation_ix, delta, done) + }; let delta = (self.animations[animation_ix].easing)(delta); debug_assert!( @@ -259,3 +287,100 @@ mod easing { } } } + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc, time::Duration}; + + use crate::{ + Animation, Context, InteractiveElement, Render, TestAppContext, WindowHandle, div, + prelude::*, px, size, + }; + + use super::*; + + struct AnimationTestView { + rendered_deltas: Rc>>, + } + + impl Render for AnimationTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let rendered_deltas = self.rendered_deltas.clone(); + div().size_full().child(div().with_animation( + "repeating-animation", + Animation::new(Duration::from_secs(1)).repeat(), + move |this, delta| { + rendered_deltas.borrow_mut().push(delta); + this + }, + )) + } + } + + fn open_test_window( + cx: &mut TestAppContext, + ) -> (Rc>>, WindowHandle) { + let rendered_deltas = Rc::new(RefCell::new(Vec::new())); + let window = cx.open_window(size(px(100.), px(100.)), { + let rendered_deltas = rendered_deltas.clone(); + move |_, _| AnimationTestView { rendered_deltas } + }); + cx.run_until_parked(); + (rendered_deltas, window) + } + + fn simulate_next_frame( + window: &WindowHandle, + cx: &mut TestAppContext, + ) -> usize { + let callback_count = window + .update(cx, |_, window, cx| window.simulate_next_frame(cx)) + .unwrap(); + cx.run_until_parked(); + callback_count + } + // Before parent-animation-element, using .with_animation + // would not allow chaining .parent after. This is just a + // build check that we can call div().id().with_animation().child() + #[test] + fn test_animation_parent() { + div() + .id("id") + // + .with_animation( + "animation", + Animation::new(Duration::from_secs(1)), + |el, _t| { + // + el + }, + ) + .child( + // + div(), + ); + } + + #[gpui::test] + fn test_repeating_animation_schedules_animation_frames(cx: &mut TestAppContext) { + let (rendered_deltas, window) = open_test_window(cx); + + assert_eq!(rendered_deltas.borrow().len(), 1); + + for expected_frames in 2..=3 { + assert_eq!(simulate_next_frame(&window, cx), 1); + assert_eq!(rendered_deltas.borrow().len(), expected_frames); + } + } + + #[gpui::test] + fn test_reduce_motion_renders_single_static_frame(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); + let (rendered_deltas, window) = open_test_window(cx); + + assert_eq!(*rendered_deltas.borrow(), vec![0.0]); + + assert_eq!(simulate_next_frame(&window, cx), 0); + assert_eq!(*rendered_deltas.borrow(), vec![0.0]); + } +} diff --git a/crates/gpui/src/elements/container_query.rs b/crates/gpui/src/elements/container_query.rs new file mode 100644 index 00000000000000..0363ce6900835b --- /dev/null +++ b/crates/gpui/src/elements/container_query.rs @@ -0,0 +1,126 @@ +//! A container query element, in the spirit of CSS container queries. +//! The element's own size is determined solely by its style and the space +//! offered by its parent. + +use refineable::Refineable as _; + +use crate::{ + AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, LayoutId, Pixels, Size, Style, StyleRefinement, Styled, + Window, relative, +}; + +/// Construct a container query element with the given render callback. +/// The callback receives the size the element was assigned during layout and +/// returns the contents to display within it. +/// +/// By default the element fills its parent (equivalent to `.size_full()`); +/// use the [`Styled`] methods to size it differently. Because the contents +/// don't exist until after layout, they cannot influence the element's size. +/// +/// # Example +/// +/// ``` +/// # use gpui::{container_query, div, px, IntoElement, ParentElement}; +/// container_query(|size, _window, _cx| { +/// if size.width < px(240.) { +/// div().child("Narrow layout") +/// } else { +/// div().child("Wide layout") +/// } +/// }); +/// ``` +pub fn container_query( + render: impl 'static + FnOnce(Size, &mut Window, &mut App) -> E, +) -> ContainerQuery +where + E: IntoElement, +{ + let mut base_style = StyleRefinement::default(); + base_style.size.width = Some(relative(1.).into()); + base_style.size.height = Some(relative(1.).into()); + + ContainerQuery { + render: Some(Box::new(|size, window, cx| { + render(size, window, cx).into_any_element() + })), + style: base_style, + } +} + +/// A container query element, created with [`container_query`]. +pub struct ContainerQuery { + render: Option, &mut Window, &mut App) -> AnyElement>>, + style: StyleRefinement, +} + +impl Element for ContainerQuery { + type RequestLayoutState = (); + type PrepaintState = Option; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.refine(&self.style); + let layout_id = window.request_layout(style, [], cx); + (layout_id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Option { + let render = self.render.take()?; + let mut child = render(bounds.size, window, cx); + child.layout_as_root(bounds.size.map(AvailableSpace::Definite), window, cx); + child.prepaint_at(bounds.origin, window, cx); + Some(child) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(child) = prepaint { + child.paint(window, cx); + } + } +} + +impl IntoElement for ContainerQuery { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Styled for ContainerQuery { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} diff --git a/crates/gpui/src/elements/deferred.rs b/crates/gpui/src/elements/deferred.rs index 25245fa4b6ea70..ddf324a6b26b86 100644 --- a/crates/gpui/src/elements/deferred.rs +++ b/crates/gpui/src/elements/deferred.rs @@ -94,3 +94,113 @@ impl Deferred { self } } + +#[cfg(test)] +mod tests { + use crate::{ + Context, Entity, StyleRefinement, TestAppContext, Window, anchored, deferred, div, point, + prelude::*, px, size, + }; + + /// A stand-in for a dock panel hosting a popover (deferred draw) whose + /// content opens another popover (a deferred draw created while + /// prepainting the first one's content). + struct PanelView; + + impl Render for PanelView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().key_context("Panel").size_full().child( + deferred( + anchored().position(point(px(10.), px(10.))).child( + div().key_context("Popover").w(px(200.)).h(px(200.)).child( + deferred( + anchored().position(point(px(30.), px(30.))).child( + div() + .key_context("NestedMenu") + .debug_selector(|| "NESTED_MENU".into()) + .w(px(50.)) + .h(px(50.)), + ), + ) + .with_priority(2), + ), + ), + ) + .with_priority(1), + ) + } + } + + struct RootView { + panel: Entity, + } + + impl Render for RootView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().key_context("Root").size_full().child( + self.panel + .clone() + .cached(StyleRefinement::default().size_full()), + ) + } + } + + /// Regression test for a crash with nested deferred draws (e.g. a popover + /// menu inside a popover hosted by a cached dock panel). Prepaint indices + /// recorded during the deferred draw rounds must index the same + /// `deferred_draws` vector that `reuse_prepaint` slices on the next frame; + /// previously they were measured against a transient per-round vector, so + /// reusing the panel's subtree grafted the wrong deferred draws and + /// panicked in the dispatch tree. + #[gpui::test] + fn test_nested_deferred_draws_with_reused_views(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(800.), px(600.)), |_, cx| { + let panel = cx.new(|_| PanelView); + RootView { panel } + }); + cx.run_until_parked(); + + let menu_bounds = window + .update(cx, |_, window, _| { + window + .rendered_frame + .debug_bounds + .get("NESTED_MENU") + .copied() + }) + .unwrap() + .expect("NESTED_MENU debug bounds not found"); + assert_eq!(menu_bounds.size, size(px(50.), px(50.))); + + // Re-render only the root view; the panel is cached, so its subtree - + // including both deferred draw records - is reused from the previous + // frame. + window.update(cx, |_, _, cx| cx.notify()).unwrap(); + cx.run_until_parked(); + + // Reuse the subtree a second time, exercising ranges that were + // themselves recorded during a reused frame. + window.update(cx, |_, _, cx| cx.notify()).unwrap(); + cx.run_until_parked(); + + // Re-render the panel itself again to prove the popovers still draw. + window + .update(cx, |root, _, cx| { + root.panel.update(cx, |_, cx| cx.notify()); + }) + .unwrap(); + cx.run_until_parked(); + + window + .update(cx, |_, window, _| { + assert_eq!(window.rendered_frame.deferred_draws.len(), 2); + assert!( + window + .rendered_frame + .debug_bounds + .contains_key("NESTED_MENU") + ); + }) + .unwrap(); + } +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 4a61069a0a9b71..c02bd16b77e421 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -21,10 +21,10 @@ use crate::{ Display, Element, ElementId, Entity, EntityId, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, - MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, Overflow, - ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, - StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, - size, + MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, + MouseUpEvent, Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, + Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, + point, px, size, }; use collections::HashMap; use gpui_util::ResultExt; @@ -262,7 +262,10 @@ impl Interactivity { ) { self.mouse_down_listeners .push(Box::new(move |event, phase, hitbox, window, cx| { - if phase == DispatchPhase::Capture && !hitbox.contains(&window.mouse_position()) { + if phase == DispatchPhase::Capture + && !window.has_active_prompt() + && !hitbox.contains(&window.mouse_position()) + { (listener)(event, window, cx) } })); @@ -305,6 +308,22 @@ impl Interactivity { })); } + /// Bind the given callback to the mouse exit event, during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_mouse_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_exit( + &mut self, + listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_exit_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -919,6 +938,18 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse exit event, during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_mouse_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_exit( + mut self, + listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_exit(listener); + self + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -1197,7 +1228,26 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Set the accessible label for this element. fn aria_label(mut self, label: impl Into) -> Self { - self.interactivity().aria_label = Some(label.into()); + self.interactivity().aria.label = Some(label.into()); + self + } + + /// Set the accessible description for this element. Unlike the label (which + /// names the element), the description provides supplementary information + /// that assistive technology announces after the name, role, and value - + /// for example a settings subtitle or a hint. + fn aria_description(mut self, description: impl Into) -> Self { + self.interactivity().aria.description = Some(description.into()); + self + } + + /// Set the keyboard shortcut(s) that activate this element, announced by + /// assistive technology (maps to AccessKit's `keyboard_shortcut`). + /// + /// Note that this does not create a keymap. It simply instructs assistive + /// technology what the keymap is. + fn aria_keyshortcuts(mut self, keyshortcuts: impl Into) -> Self { + self.interactivity().aria.keyshortcuts = Some(keyshortcuts.into()); self } @@ -1240,106 +1290,106 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Set the selected state for this element. fn aria_selected(mut self, selected: bool) -> Self { - self.interactivity().aria_selected = Some(selected); + self.interactivity().aria.selected = Some(selected); self } /// Set the expanded state for this element. fn aria_expanded(mut self, expanded: bool) -> Self { - self.interactivity().aria_expanded = Some(expanded); + self.interactivity().aria.expanded = Some(expanded); self } /// Set the toggled state for this element. fn aria_toggled(mut self, toggled: accesskit::Toggled) -> Self { - self.interactivity().aria_toggled = Some(toggled); + self.interactivity().aria.toggled = Some(toggled); self } /// Set the numeric value for this element. fn aria_numeric_value(mut self, value: f64) -> Self { - self.interactivity().aria_numeric_value = Some(value); + self.interactivity().aria.numeric_value = Some(value); self } /// Set the step by which assistive technology should expect the numeric /// value of this element to change (e.g. when incrementing a spin button). fn aria_numeric_value_step(mut self, step: f64) -> Self { - self.interactivity().aria_numeric_value_step = Some(step); + self.interactivity().aria.numeric_value_step = Some(step); self } /// Set the string value of this element, e.g. the text content of a simple /// text input. fn aria_value(mut self, value: impl Into) -> Self { - self.interactivity().aria_value = Some(value.into()); + self.interactivity().aria.value = Some(value.into()); self } /// Set the placeholder text reported to assistive technology for this /// element, shown when a text input is empty. fn aria_placeholder(mut self, placeholder: impl Into) -> Self { - self.interactivity().aria_placeholder = Some(placeholder.into()); + self.interactivity().aria.placeholder = Some(placeholder.into()); self } /// Set the minimum numeric value for this element. fn aria_min_numeric_value(mut self, value: f64) -> Self { - self.interactivity().aria_min_numeric_value = Some(value); + self.interactivity().aria.min_numeric_value = Some(value); self } /// Set the maximum numeric value for this element. fn aria_max_numeric_value(mut self, value: f64) -> Self { - self.interactivity().aria_max_numeric_value = Some(value); + self.interactivity().aria.max_numeric_value = Some(value); self } /// Set the orientation of this element. fn aria_orientation(mut self, orientation: accesskit::Orientation) -> Self { - self.interactivity().aria_orientation = Some(orientation); + self.interactivity().aria.orientation = Some(orientation); self } /// Set the heading level of this element. fn aria_level(mut self, level: usize) -> Self { - self.interactivity().aria_level = Some(level); + self.interactivity().aria.level = Some(level); self } /// Set the position in set of this element. fn aria_position_in_set(mut self, position: usize) -> Self { - self.interactivity().aria_position_in_set = Some(position); + self.interactivity().aria.position_in_set = Some(position); self } /// Set the size of set for this element. fn aria_size_of_set(mut self, size: usize) -> Self { - self.interactivity().aria_size_of_set = Some(size); + self.interactivity().aria.size_of_set = Some(size); self } /// Set the row index for this element. fn aria_row_index(mut self, index: usize) -> Self { - self.interactivity().aria_row_index = Some(index); + self.interactivity().aria.row_index = Some(index); self } /// Set the column index for this element. fn aria_column_index(mut self, index: usize) -> Self { - self.interactivity().aria_column_index = Some(index); + self.interactivity().aria.column_index = Some(index); self } /// Set the row count for this element. fn aria_row_count(mut self, count: usize) -> Self { - self.interactivity().aria_row_count = Some(count); + self.interactivity().aria.row_count = Some(count); self } /// Set the column count for this element. fn aria_column_count(mut self, count: usize) -> Self { - self.interactivity().aria_column_count = Some(count); + self.interactivity().aria.column_count = Some(count); self } @@ -1525,6 +1575,8 @@ pub(crate) type MousePressureListener = Box; pub(crate) type MouseMoveListener = Box; +pub(crate) type MouseExitListener = + Box; pub(crate) type ScrollWheelListener = Box; @@ -1868,6 +1920,30 @@ impl IntoElement for Div { } } +#[derive(Default)] +pub(crate) struct AriaProperties { + pub(crate) label: Option, + pub(crate) description: Option, + pub(crate) keyshortcuts: Option, + pub(crate) selected: Option, + pub(crate) expanded: Option, + pub(crate) toggled: Option, + pub(crate) numeric_value: Option, + pub(crate) min_numeric_value: Option, + pub(crate) max_numeric_value: Option, + pub(crate) numeric_value_step: Option, + pub(crate) value: Option, + pub(crate) placeholder: Option, + pub(crate) orientation: Option, + pub(crate) level: Option, + pub(crate) position_in_set: Option, + pub(crate) size_of_set: Option, + pub(crate) row_index: Option, + pub(crate) column_index: Option, + pub(crate) row_count: Option, + pub(crate) column_count: Option, +} + /// The interactivity struct. Powers all of the general-purpose /// interactivity in the `Div` element. #[derive(Default)] @@ -1907,6 +1983,7 @@ pub struct Interactivity { pub(crate) mouse_up_listeners: Vec, pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, + pub(crate) mouse_exit_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -1932,24 +2009,7 @@ pub struct Interactivity { pub(crate) a11y_synthetic_children: Option>, pub(crate) report_active_descendant_focus: bool, pub(crate) override_role: Option, - pub(crate) aria_label: Option, - pub(crate) aria_selected: Option, - pub(crate) aria_expanded: Option, - pub(crate) aria_toggled: Option, - pub(crate) aria_numeric_value: Option, - pub(crate) aria_min_numeric_value: Option, - pub(crate) aria_max_numeric_value: Option, - pub(crate) aria_numeric_value_step: Option, - pub(crate) aria_value: Option, - pub(crate) aria_placeholder: Option, - pub(crate) aria_orientation: Option, - pub(crate) aria_level: Option, - pub(crate) aria_position_in_set: Option, - pub(crate) aria_size_of_set: Option, - pub(crate) aria_row_index: Option, - pub(crate) aria_column_index: Option, - pub(crate) aria_row_count: Option, - pub(crate) aria_column_count: Option, + pub(crate) aria: AriaProperties, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -2079,6 +2139,13 @@ impl Interactivity { if focus_handle.is_focused(window) { window.a11y.set_focus(node_id); } + } else if focus_handle.is_focused(window) { + // Focusable, but with no element id it can't have an + // accessibility node, so screen readers fall back to the + // whole window. + window + .a11y + .note_focus_without_node(focus_handle.id, "it has no element id"); } } } @@ -2152,12 +2219,14 @@ impl Interactivity { || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() + || !self.mouse_exit_listeners.is_empty() || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() || self.has_pinch_listeners() || self.drag_listener.is_some() || !self.drop_listeners.is_empty() + || !self.drag_over_styles.is_empty() || self.tooltip_builder.is_some() || window.is_inspector_picking(cx) } @@ -2265,9 +2334,6 @@ impl Interactivity { if self.tab_group { tab_group = self.tab_index; } - if let Some(focus_handle) = &self.tracked_focus_handle { - window.next_frame.tab_stops.insert(focus_handle); - } window.with_element_opacity(style.opacity, |window| { style.paint(bounds, window, cx, |window: &mut Window, cx: &mut App| { @@ -2276,6 +2342,17 @@ impl Interactivity { style.overflow_mask(bounds, window.rem_size()), |window| { window.with_tab_group(tab_group, |window| { + // Register the container's own focus handle *inside* its + // tab group, so that focusing the container and then + // calling `focus_next` descends into this group's first + // item. Inserting it before `with_tab_group` would give the + // container a shallower tab path than its children; with + // sibling groups every container would then sort ahead of + // every item, and `focus_next` from a container would jump + // to the first item in the whole window instead of its own. + if let Some(focus_handle) = &self.tracked_focus_handle { + window.next_frame.tab_stops.insert(focus_handle); + } if let Some(hitbox) = hitbox { #[cfg(debug_assertions)] self.paint_debug_info( @@ -2531,6 +2608,13 @@ impl Interactivity { }) } + for listener in self.mouse_exit_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MouseExitEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.scroll_wheel_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { @@ -2588,8 +2672,8 @@ impl Interactivity { if phase == DispatchPhase::Capture && group_hovered != was_group_hovered { if let Some(hover_state) = &hover_state { hover_state.borrow_mut().group = group_hovered; + cx.notify(current_view); } - cx.notify(current_view); } }); } @@ -2821,7 +2905,6 @@ impl Interactivity { } if let Some(hover_listener) = self.hover_listener.take() { - let hitbox = hitbox.clone(); let was_hovered = element_state .hover_listener_state .get_or_insert_with(Default::default) @@ -2830,22 +2913,35 @@ impl Interactivity { .pending_mouse_down .get_or_insert_with(Default::default) .clone(); - - window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - let is_hovered = has_mouse_down.borrow().is_none() - && !cx.has_active_drag() - && hitbox.is_hovered(window); + let hover_listener = Rc::new(hover_listener); + let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| { let mut was_hovered = was_hovered.borrow_mut(); - if is_hovered != *was_hovered { *was_hovered = is_hovered; drop(was_hovered); - hover_listener(&is_hovered, window, cx); } + }; + + window.on_mouse_event({ + let update_hover = update_hover.clone(); + let hitbox = hitbox.clone(); + move |_: &MouseMoveEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + let is_hovered = has_mouse_down.borrow().is_none() + && !cx.has_active_drag() + && hitbox.is_hovered(window); + update_hover(is_hovered, window, cx); + } + } + }); + + // The pointer can leave the window without a final MouseMove, so also + // clear hover on MouseExited. + window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + update_hover(false, window, cx); + } }); } @@ -3174,58 +3270,64 @@ impl Interactivity { } pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) { - if let Some(label) = &self.aria_label { + if let Some(label) = &self.aria.label { node.set_label(label.to_string()); } - if let Some(selected) = self.aria_selected { + if let Some(description) = &self.aria.description { + node.set_description(description.to_string()); + } + if let Some(keyshortcuts) = &self.aria.keyshortcuts { + node.set_keyboard_shortcut(keyshortcuts.to_string()); + } + if let Some(selected) = self.aria.selected { node.set_selected(selected); } - if let Some(expanded) = self.aria_expanded { + if let Some(expanded) = self.aria.expanded { node.set_expanded(expanded); } - if let Some(toggled) = self.aria_toggled { + if let Some(toggled) = self.aria.toggled { node.set_toggled(toggled); } - if let Some(value) = self.aria_numeric_value { + if let Some(value) = self.aria.numeric_value { node.set_numeric_value(value); } - if let Some(value) = self.aria_min_numeric_value { + if let Some(value) = self.aria.min_numeric_value { node.set_min_numeric_value(value); } - if let Some(value) = self.aria_max_numeric_value { + if let Some(value) = self.aria.max_numeric_value { node.set_max_numeric_value(value); } - if let Some(step) = self.aria_numeric_value_step { + if let Some(step) = self.aria.numeric_value_step { node.set_numeric_value_step(step); } - if let Some(value) = &self.aria_value { + if let Some(value) = &self.aria.value { node.set_value(value.to_string()); } - if let Some(placeholder) = &self.aria_placeholder { + if let Some(placeholder) = &self.aria.placeholder { node.set_placeholder(placeholder.to_string()); } - if let Some(orientation) = self.aria_orientation { + if let Some(orientation) = self.aria.orientation { node.set_orientation(orientation); } - if let Some(level) = self.aria_level { + if let Some(level) = self.aria.level { node.set_level(level); } - if let Some(position) = self.aria_position_in_set { + if let Some(position) = self.aria.position_in_set { node.set_position_in_set(position); } - if let Some(size) = self.aria_size_of_set { + if let Some(size) = self.aria.size_of_set { node.set_size_of_set(size); } - if let Some(index) = self.aria_row_index { + if let Some(index) = self.aria.row_index { node.set_row_index(index); } - if let Some(index) = self.aria_column_index { + if let Some(index) = self.aria.column_index { node.set_column_index(index); } - if let Some(count) = self.aria_row_count { + if let Some(count) = self.aria.row_count { node.set_row_count(count); } - if let Some(count) = self.aria_column_count { + if let Some(count) = self.aria.column_count { node.set_column_count(count); } if !self.click_listeners.is_empty() { @@ -4015,9 +4117,108 @@ mod tests { use super::*; use crate::{ AnyWindowHandle, AppContext as _, Context, InputEvent, Keystroke, MouseMoveEvent, - TestAppContext, util::FluentBuilder as _, + TestAppContext, canvas, util::FluentBuilder as _, }; - use std::rc::Weak; + use std::{cell::Cell, rc::Weak}; + + struct GroupHoverTestView { + render_count: Rc>, + anonymous_paint_count: Rc>, + stateful_width: Rc>, + } + + impl Render for GroupHoverTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.render_count.set(self.render_count.get() + 1); + let anonymous_paint_count = self.anonymous_paint_count.clone(); + let stateful_width = self.stateful_width.clone(); + div().size_full().child( + div() + .ml(px(20.)) + .mt(px(20.)) + .size(px(50.)) + .relative() + .group("hover-group") + .child( + div() + .absolute() + .size_full() + .invisible() + .group_hover("hover-group", |style| style.visible()) + .child(canvas( + |_, _, _| {}, + move |_, _, _, _| { + anonymous_paint_count.set(anonymous_paint_count.get() + 1) + }, + )), + ) + .child( + div() + .id("stateful-group-hover-target") + .absolute() + .top_0() + .left_0() + .size(px(10.)) + .group_hover("hover-group", |style| style.size(px(20.))) + .child(canvas( + move |bounds, _, _| stateful_width.set(bounds.size.width), + |_, _, _, _| {}, + )), + ), + ) + } + } + + #[gpui::test] + fn group_hover_styles_update_only_on_transitions(cx: &mut TestAppContext) { + let render_count = Rc::new(Cell::new(0)); + let anonymous_paint_count = Rc::new(Cell::new(0)); + let stateful_width = Rc::new(Cell::new(px(0.))); + let window = cx.add_window({ + let render_count = render_count.clone(); + let anonymous_paint_count = anonymous_paint_count.clone(); + let stateful_width = stateful_width.clone(); + move |_, _| GroupHoverTestView { + render_count, + anonymous_paint_count, + stateful_width, + } + }); + let window = AnyWindowHandle::from(window); + + cx.update_window(window, |_, window, cx| window.draw(cx).clear()) + .unwrap(); + assert_eq!(anonymous_paint_count.get(), 0); + assert_eq!(stateful_width.get(), px(10.)); + + let move_mouse = |cx: &mut TestAppContext, position| { + cx.update_window(window, |_, window, cx| { + window.simulate_mouse_move(position, cx) + }) + .unwrap(); + }; + + let initial_render_count = render_count.get(); + move_mouse(cx, point(px(25.), px(25.))); + assert_eq!(render_count.get(), initial_render_count + 1); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(20.)); + + move_mouse(cx, point(px(30.), px(30.))); + assert_eq!(render_count.get(), initial_render_count + 1); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(20.)); + + move_mouse(cx, point(px(5.), px(5.))); + assert_eq!(render_count.get(), initial_render_count + 2); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(10.)); + + move_mouse(cx, point(px(10.), px(10.))); + assert_eq!(render_count.get(), initial_render_count + 2); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(10.)); + } struct TestTooltipView; @@ -4325,16 +4526,97 @@ mod tests { assert!(active_tooltip.borrow().is_none()); } + struct MouseDownOutOwner { + mouse_down_out_count: Rc>, + } + + impl Render for MouseDownOutOwner { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let mouse_down_out_count = self.mouse_down_out_count.clone(); + div() + .size_full() + .child(div().id("target").w(px(50.)).h(px(50.)).on_mouse_down_out( + move |_, _, _| { + *mouse_down_out_count.borrow_mut() += 1; + }, + )) + } + } + + #[test] + fn mouse_down_out_is_suppressed_while_window_prompt_is_active() { + let mut test_app = TestAppContext::single(); + let mouse_down_out_count = Rc::new(RefCell::new(0)); + let window = test_app.add_window({ + let mouse_down_out_count = mouse_down_out_count.clone(); + move |_, _| MouseDownOutOwner { + mouse_down_out_count, + } + }); + let any_window: AnyWindowHandle = window.into(); + + fn dispatch_mouse_down_outside_target( + test_app: &mut TestAppContext, + any_window: AnyWindowHandle, + ) { + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + MouseDownEvent { + position: point(px(75.), px(75.)), + button: MouseButton::Left, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + } + + test_app + .update_window(any_window, |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + dispatch_mouse_down_outside_target(&mut test_app, any_window); + assert_eq!( + *mouse_down_out_count.borrow(), + 1, + "mouse down outside the element should fire mouse-down-out listeners" + ); + + test_app + .update_window(any_window, |_, window, cx| { + cx.set_prompt_builder(crate::fallback_prompt_renderer); + let _receiver = + window.prompt(crate::PromptLevel::Warning, "message", None, &["Ok"], cx); + assert!(window.has_active_prompt()); + window.draw(cx).clear(); + }) + .unwrap(); + + dispatch_mouse_down_outside_target(&mut test_app, any_window); + assert_eq!( + *mouse_down_out_count.borrow(), + 1, + "mouse down over an active prompt should not fire mouse-down-out listeners" + ); + } + #[test] fn test_write_a11y_info_string_and_numeric_properties() { let mut interactivity = Interactivity::default(); - interactivity.aria_label = Some("Buffer Font Size".into()); - interactivity.aria_value = Some("15".into()); - interactivity.aria_placeholder = Some("Search".into()); - interactivity.aria_numeric_value = Some(15.0); - interactivity.aria_min_numeric_value = Some(6.0); - interactivity.aria_max_numeric_value = Some(72.0); - interactivity.aria_numeric_value_step = Some(1.0); + interactivity.aria.label = Some("Buffer Font Size".into()); + interactivity.aria.value = Some("15".into()); + interactivity.aria.placeholder = Some("Search".into()); + interactivity.aria.numeric_value = Some(15.0); + interactivity.aria.min_numeric_value = Some(6.0); + interactivity.aria.max_numeric_value = Some(72.0); + interactivity.aria.numeric_value_step = Some(1.0); let mut node = accesskit::Node::new(accesskit::Role::SpinButton); interactivity.write_a11y_info(&mut node); @@ -4552,4 +4834,69 @@ mod tests { assert!(clicks.borrow().is_empty(), "clicks: {:?}", clicks.borrow()); } + + /// Two sibling tab groups, each a focusable container that is *not* itself a + /// tab stop and holds a single tab stop. Mirrors how the title bar and + /// status bar expose their controls as ARIA toolbars. + struct TabGroupFocus { + group_a: FocusHandle, + item_a: FocusHandle, + group_b: FocusHandle, + item_b: FocusHandle, + } + + impl Render for TabGroupFocus { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + fn group(container: &FocusHandle, item: &FocusHandle) -> Div { + div() + .track_focus(container) + .tab_group() + .child(div().track_focus(item)) + } + div() + .child(group(&self.group_a, &self.item_a)) + .child(group(&self.group_b, &self.item_b)) + } + } + + /// Focusing a tab-group container and pressing Tab (`focus_next`) must move + /// focus to the first tab stop *inside that container*, as documented on + /// [`InteractiveElement::tab_stop`]. + #[test] + fn focus_next_from_tab_group_container_enters_that_group() { + let mut cx = TestAppContext::single(); + let (group_a, item_a, group_b, item_b) = cx.update(|cx| { + ( + cx.focus_handle(), + cx.focus_handle().tab_stop(true), + cx.focus_handle(), + cx.focus_handle().tab_stop(true), + ) + }); + let window: AnyWindowHandle = cx + .add_window({ + let (group_a, item_a, group_b, item_b) = + (group_a, item_a, group_b.clone(), item_b.clone()); + move |_, _| TabGroupFocus { + group_a, + item_a, + group_b, + item_b, + } + }) + .into(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear()) + .unwrap(); + + // Focus the *second* group's container, then advance like Tab would. + let focused = cx + .update_window(window, |_, window, cx| { + window.focus(&group_b, cx); + window.focus_next(cx); + window.focused(cx).map(|handle| handle.id) + }) + .unwrap(); + + assert_eq!(focused, Some(item_b.id)); + } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 6f01182f9e7666..7e92ecba3d0ef4 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -2,14 +2,15 @@ use crate::{ AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, - SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px, + SharedString, SharedUri, StyleRefinement, Styled, Task, Window, decode_static_image, + decode_static_image_from_decoder, px, }; use anyhow::Result; use futures::Future; use gpui_util::ResultExt; use image::{ - AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba, + AnimationDecoder, ImageError, ImageFormat, Rgba, codecs::{gif::GifDecoder, webp::WebPDecoder}, }; use scheduler::Instant; @@ -317,7 +318,7 @@ impl Element for Img { if let Some(state) = &mut state { state.frame_index = state.frame_index.min(max_frame_index); - if frame_count > 1 { + if frame_count > 1 && !cx.reduce_motion() { if window.is_window_active() { let current_time = Instant::now(); if let Some(last_frame_time) = state.last_frame_time { @@ -378,6 +379,7 @@ impl Element for Img { if global_id.is_some() && data.frame_count() > 1 && window.is_window_active() + && !cx.reduce_motion() { window.request_animation_frame(); } @@ -715,27 +717,10 @@ impl Asset for ImageAssetLoader { frames } else { - let mut data = DynamicImage::from_decoder(decoder)?.into_rgba8(); - - // Convert from RGBA to BGRA. - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - - SmallVec::from_elem(Frame::new(data), 1) + decode_static_image_from_decoder(decoder)? } } - _ => { - let mut data = - image::load_from_memory_with_format(&bytes, format)?.into_rgba8(); - - // Convert from RGBA to BGRA. - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - - SmallVec::from_elem(Frame::new(data), 1) - } + _ => decode_static_image(&bytes, format)?, }; Ok(Arc::new(RenderImage::new(data))) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index bd4c45b4ada3f7..0341f1e9854ed9 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -338,6 +338,17 @@ impl ListState { self } + /// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb + /// is correctly sized from the first frame, without measuring all items up front. + /// + /// As items are actually rendered their real heights replace the hint, so the scrollbar + /// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`] + /// for lists where items have roughly uniform heights (e.g. table rows). + pub fn with_uniform_item_height(self, height: Pixels) -> Self { + self.apply_uniform_item_height(height); + self + } + /// Reset this instantiation of the list state. /// /// Note that this will cause scroll events to be dropped until the next paint. @@ -355,6 +366,33 @@ impl ListState { self.splice(0..old_count, element_count); } + /// Reset the list to `element_count` items, pre-populating every item with a + /// uniform height hint so the scrollbar thumb is correctly sized from the first + /// frame even for off-screen items. + pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) { + self.reset(element_count); + self.apply_uniform_item_height(height); + } + + fn apply_uniform_item_height(&self, height: Pixels) { + let size_hint = Size { + width: px(0.), + height, + }; + let mut state = self.0.borrow_mut(); + let new_items = state + .items + .iter() + .map(|item| ListItem::Unmeasured { + size_hint: Some(item.size_hint().unwrap_or(size_hint)), + focus_handle: item.focus_handle(), + }) + .collect::>(); + let mut tree = SumTree::default(); + tree.extend(new_items, ()); + state.items = tree; + } + /// Remeasure all items while preserving proportional scroll position. /// /// Use this when item heights may have changed (e.g., font size changes) @@ -1000,7 +1038,7 @@ impl StateInner { let mut rendered_focused_item = false; let available_item_space = size( - available_width.map_or(AvailableSpace::MinContent, |width| { + available_width.map_or(AvailableSpace::MaxContent, |width| { AvailableSpace::Definite(width) }), AvailableSpace::MinContent, diff --git a/crates/gpui/src/elements/mod.rs b/crates/gpui/src/elements/mod.rs index bfbc08b3f49792..8a2a1b70a7bf28 100644 --- a/crates/gpui/src/elements/mod.rs +++ b/crates/gpui/src/elements/mod.rs @@ -1,6 +1,7 @@ mod anchored; mod animation; mod canvas; +mod container_query; mod deferred; mod div; mod image_cache; @@ -14,6 +15,7 @@ mod uniform_list; pub use anchored::*; pub use animation::*; pub use canvas::*; +pub use container_query::*; pub use deferred::*; pub use div::*; pub use image_cache::*; diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index b64f65a37c511c..3470ac94e27ed3 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -618,6 +618,7 @@ struct TextLayoutInner { lines: SmallVec<[WrappedLine; 1]>, line_height: Pixels, wrap_width: Option, + truncate_width: Option, size: Option>, bounds: Option>, } @@ -680,16 +681,20 @@ impl TextLayout { // 2. wrap_width matches (or both are None) // 3. truncate_width is None (if truncate_width is Some, we need to re-layout // because the previous layout may have been computed without truncation) + // 4. the cached layout was not truncated (a truncated layout answers an + // unconstrained probe with the truncated size, which poisons intrinsic + // sizing with whatever width some earlier measure pass happened to use) if let Some(text_layout) = element_state.0.borrow().as_ref() && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) && truncate_width.is_none() + && text_layout.truncate_width.is_none() { return size; } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let (text, runs) = if truncate_width.is_some() { + let (text, runs) = if let Some(truncate_width) = truncate_width { if let Some(max_lines) = text_style.line_clamp && let Some(wrap_width) = wrap_width { @@ -701,10 +706,25 @@ impl TextLayout { &runs, truncate_from, ) + } else if let Some(unclipped) = window + .text_system() + .shape_text(text.clone(), font_size, &runs, None, None) + .log_err() + && unclipped + .iter() + .all(|line| line.size(line_height).width <= truncate_width) + { + // The truncation decision below sums per-character advances, + // which overestimates the shaped width (no kerning), truncating + // text that fits exactly in its measured width. Skip truncation + // whenever the honestly-shaped text fits; the shaping result + // comes from the line layout cache when the same text was + // already measured untruncated this frame. + (text.clone(), Cow::Borrowed(&*runs)) } else { line_wrapper.truncate_line( text.clone(), - truncate_width.unwrap_or(Pixels::MAX), + truncate_width, &truncation_affix, &runs, truncate_from, @@ -731,6 +751,7 @@ impl TextLayout { len: 0, line_height, wrap_width, + truncate_width, size: Some(Size::default()), bounds: None, }); @@ -749,6 +770,7 @@ impl TextLayout { len, line_height, wrap_width, + truncate_width, size: Some(size), bounds: None, }); diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 476d3358c79241..e74b095558ee58 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -671,7 +671,7 @@ impl UniformList { return Size::default(); }; let available_space = size( - list_width.map_or(AvailableSpace::MinContent, |width| { + list_width.map_or(AvailableSpace::MaxContent, |width| { AvailableSpace::Definite(width) }), AvailableSpace::MinContent, diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs new file mode 100644 index 00000000000000..5e14d4249bc93d --- /dev/null +++ b/crates/gpui/src/gestures.rs @@ -0,0 +1,123 @@ +//! Touch gesture recognition vocabulary. +//! +//! GPUI recognizes gestures from raw [`TouchEvent`](crate::TouchEvent)s in a +//! single, portable arena in gpui core: recognizers compete for in-flight +//! touches, winners claim them, and losers are cancelled. Recognized gestures +//! are surfaced through *existing* semantic events wherever possible, a tap +//! becomes [`ClickEvent::Touch`](crate::ClickEvent), a pan becomes +//! [`ScrollWheelEvent`](crate::ScrollWheelEvent)s carrying a +//! [`TouchPhase`](crate::TouchPhase), and a pinch becomes +//! [`PinchEvent`](crate::PinchEvent)s — so components written against +//! `on_click` and scroll containers work untouched on mobile. + +use std::time::Duration; + +use crate::{Pixels, Point, px}; + +/// Feel constants consumed by gesture recognizers. Provided on a best-effort +/// basis, depending on each platform's support, defaulting to GPUI's own +/// (iOS flavored) values +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GestureTuning { + /// Distance a touch may travel before it stops being a potential tap and + /// becomes a pan/drag. + pub touch_slop: Pixels, + /// Maximum interval between taps for them to accumulate a tap count. + pub multi_tap_interval: Duration, + /// Maximum distance between taps for them to accumulate a tap count. + pub multi_tap_slop: Pixels, + /// How long a touch must remain within [`Self::touch_slop`] to be + /// recognized as a long press. + pub long_press_duration: Duration, + /// Per-millisecond decay factor applied to scroll momentum after a fling. + /// (`UIScrollView` uses `0.998` per millisecond for its normal + /// deceleration rate.) + pub momentum_decay_per_ms: f32, + /// Minimum release velocity, in pixels per second, required to start + /// scroll momentum. + pub min_fling_velocity: f32, +} + +impl Default for GestureTuning { + fn default() -> Self { + Self { + touch_slop: px(8.), + multi_tap_interval: Duration::from_millis(400), + multi_tap_slop: px(16.), + long_press_duration: Duration::from_millis(500), + momentum_decay_per_ms: 0.998, + min_fling_velocity: 50., + } + } +} + +/// The set of gesture kinds that participate in recognition. +/// +/// Used by [`PlatformGestures::native_recognizers`] to declare which gestures +/// the platform recognizes natively rather than leaving to gpui core's +/// portable recognizers. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GestureKinds { + /// Tap (and multi-tap), surfaced as [`ClickEvent::Touch`](crate::ClickEvent). + pub tap: bool, + /// Long press, surfaced as [`LongPressEvent`]. + pub long_press: bool, + /// Pan/scroll (including fling momentum), surfaced as + /// [`ScrollWheelEvent`](crate::ScrollWheelEvent)s. + pub pan: bool, + /// Pinch to zoom, surfaced as [`PinchEvent`](crate::PinchEvent)s. + pub pinch: bool, +} + +impl GestureKinds { + /// No gestures; gpui core's portable recognizers handle everything. + pub const NONE: Self = Self { + tap: false, + long_press: false, + pan: false, + pinch: false, + }; + + /// All gesture kinds. + pub const ALL: Self = Self { + tap: true, + long_press: true, + pan: true, + pinch: true, + }; +} + +/// A long-press gesture, mobile's context-menu trigger. +/// +/// A bare long press is surfaced as a [`ClickEvent`](crate::ClickEvent) with +/// `long_press: true`, delivered to aux-click listeners alongside right +/// clicks. This event is the raw hook for elements that need the gesture +/// itself (e.g. long-press to start a drag); the registration API ships +/// together with the gesture arena. +#[derive(Clone, Debug, Default)] +pub struct LongPressEvent { + /// The position of the touch that was recognized as a long press. + pub position: Point, +} + +/// Platform gesture recognition services. +/// +/// If your mobile platform supports native gesture recognition, use this +/// to share it with GPUI. +pub trait PlatformGestures { + /// Feel constants for the portable recognizers on this platform. + fn tuning(&self) -> GestureTuning { + GestureTuning::default() + } + + /// The gesture kinds this platform recognizes natively. + fn native_recognizers(&self) -> GestureKinds { + GestureKinds::NONE + } +} + +/// A no-op [`PlatformGestures`] implementation: no native recognizers and +/// default tuning. Suitable for desktop platforms and tests. +pub struct NullPlatformGestures; + +impl PlatformGestures for NullPlatformGestures {} diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index a81ff265c3edd8..4b1679cbe440fa 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -24,6 +24,7 @@ mod executor; mod platform_scheduler; pub(crate) use platform_scheduler::PlatformScheduler; mod geometry; +mod gestures; mod global; mod input; mod inspector; @@ -98,6 +99,7 @@ pub use element::*; pub use elements::*; pub use executor::*; pub use geometry::*; +pub use gestures::*; pub use global::*; pub use gpui_macros::{ AppContext, IntoElement, Render, VisualContext, bench, property_test, register_action, test, diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 10ca46501d8a82..4e204e97880c85 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -71,6 +71,24 @@ pub trait EntityInputHandler: 'static + Sized { cx: &mut Context, ) -> Option; + /// See [`InputHandler::set_selected_text_range`] for details + fn set_selected_text_range( + &mut self, + _range_utf16: Range, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + /// See [`InputHandler::text_length_utf16`] for details + fn text_length_utf16( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + /// See [`InputHandler::accepts_text_input`] for details fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { true @@ -183,6 +201,26 @@ impl InputHandler for ElementInputHandler { }) } + fn set_selected_text_range( + &mut self, + range_utf16: Range, + window: &mut Window, + cx: &mut App, + ) { + self.view.update(cx, |view, cx| { + view.set_selected_text_range(range_utf16, window, cx) + }) + } + + fn element_bounds(&mut self, _window: &mut Window, _cx: &mut App) -> Option> { + Some(self.element_bounds) + } + + fn text_length_utf16(&mut self, window: &mut Window, cx: &mut App) -> Option { + self.view + .update(cx, |view, cx| view.text_length_utf16(window, cx)) + } + fn accepts_text_input(&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 0c7f2f9c97c59f..1feee49edb0057 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -84,7 +84,7 @@ impl Deref for ModifiersChangedEvent { /// The phase of a touch motion event. /// Based on the winit enum of the same name. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TouchPhase { /// The touch started. Started, @@ -93,6 +93,45 @@ pub enum TouchPhase { Moved, /// The touch phase has ended Ended, + /// The touch was cancelled: the system took it and it will not end + /// normally. Consumers must fully unwind any in-progress interaction, + /// treating the touch as if it never committed. + Cancelled, +} + +/// Identifies one touch (finger or stylus contact) for its lifetime, from +/// [`TouchPhase::Started`] through [`TouchPhase::Ended`] or +/// [`TouchPhase::Cancelled`]. +/// +/// The value is opaque and platform-defined; it is only guaranteed to be +/// stable for the duration of the touch and unique among concurrent touches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TouchId(pub u64); + +/// A raw touch event from the platform. +/// +/// +/// Dispatch contract (core implementation pending): a touch is hit-tested +/// once, at [`TouchPhase::Started`], occlusion-aware; all subsequent events +/// for the same [`TouchId`] are delivered to the elements under the starting +/// position, even after the touch moves outside them. +#[derive(Clone, Debug, Default)] +pub struct TouchEvent { + /// Which touch this event belongs to. + pub id: TouchId, + /// The phase of the touch. + pub phase: TouchPhase, + /// The position of the touch in window coordinates. + pub position: Point, + /// Normalized touch force in `0.0..=1.0`, if the hardware reports it. + pub force: Option, +} + +impl Sealed for TouchEvent {} +impl InputEvent for TouchEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::Touch(self) + } } /// A mouse down event from the platform @@ -221,13 +260,31 @@ pub struct KeyboardClickEvent { pub bounds: Bounds, } -/// A click event, generated when a mouse button or keyboard button is pressed and released. +/// A click event that was generated by a recognized tap gesture on a touch +/// screen. +#[derive(Clone, Debug, Default)] +pub struct TouchClickEvent { + /// The position of the tap in window coordinates. + pub position: Point, + /// The number of consecutive taps at this location (double tap = 2), + /// analogous to the mouse `click_count`. + pub tap_count: usize, + /// Whether this was a long press rather than a tap. Long presses are + /// touch's secondary activation: they are delivered to aux-click + /// listeners alongside right clicks, not to primary click listeners. + pub long_press: bool, +} + +/// A click event, generated when a mouse button or keyboard button is pressed and released, +/// or when a tap gesture is recognized on a touch screen. #[derive(Clone, Debug)] pub enum ClickEvent { /// A click event trigger by a mouse button being pressed and released. Mouse(MouseClickEvent), /// A click event trigger by a keyboard button being pressed and released. Keyboard(KeyboardClickEvent), + /// A click event triggered by a recognized tap gesture on a touch screen. + Touch(TouchClickEvent), } impl Default for ClickEvent { @@ -249,6 +306,8 @@ impl ClickEvent { // tested via observing the behavior of the `ClickEvent.shiftKey` field in Chrome 138 // under various combinations of modifiers and keyUp / keyDown events. ClickEvent::Mouse(event) => event.up.modifiers, + // Touch screens have no modifier keys. + ClickEvent::Touch(_) => Modifiers::default(), } } @@ -256,10 +315,12 @@ impl ClickEvent { /// /// `Keyboard`: The bottom left corner of the clicked hitbox /// `Mouse`: The position of the mouse when the button was released. + /// `Touch`: The position of the tap. pub fn position(&self) -> Point { match self { ClickEvent::Keyboard(event) => event.bounds.bottom_left(), ClickEvent::Mouse(event) => event.up.position, + ClickEvent::Touch(event) => event.position, } } @@ -267,10 +328,12 @@ impl ClickEvent { /// /// `Keyboard`: None /// `Mouse`: The position of the mouse when the button was released. + /// `Touch`: None, touches are not mouse input and there is no cursor. pub fn mouse_position(&self) -> Option> { match self { ClickEvent::Keyboard(_) => None, ClickEvent::Mouse(event) => Some(event.up.position), + ClickEvent::Touch(_) => None, } } @@ -284,6 +347,7 @@ impl ClickEvent { ClickEvent::Mouse(event) => { event.down.button == MouseButton::Right && event.up.button == MouseButton::Right } + ClickEvent::Touch(_) => false, } } @@ -297,6 +361,21 @@ impl ClickEvent { ClickEvent::Mouse(event) => { event.down.button == MouseButton::Middle && event.up.button == MouseButton::Middle } + ClickEvent::Touch(_) => false, + } + } + + /// Returns whether the click is a secondary activation, i.e. a context + /// menu trigger: a right click from a mouse (macOS ctrl-clicks arrive + /// already converted to right clicks by the platform layer), or a long + /// press on a touch screen. + pub fn is_secondary(&self) -> bool { + match self { + ClickEvent::Keyboard(_) => false, + ClickEvent::Mouse(event) => { + event.down.button == MouseButton::Right && event.up.button == MouseButton::Right + } + ClickEvent::Touch(event) => event.long_press, } } @@ -304,12 +383,14 @@ impl ClickEvent { /// /// `Keyboard`: Always true /// `Mouse`: Left button pressed and released + /// `Touch`: A tap, but not a long press pub fn standard_click(&self) -> bool { match self { ClickEvent::Keyboard(_) => true, ClickEvent::Mouse(event) => { event.down.button == MouseButton::Left && event.up.button == MouseButton::Left } + ClickEvent::Touch(event) => !event.long_press, } } @@ -317,10 +398,12 @@ impl ClickEvent { /// /// `Keyboard`: false, keyboard clicks only work if an element is already focused /// `Mouse`: Whether this was the first focusing click + /// `Touch`: false, mobile windows are already active when tappable pub fn first_focus(&self) -> bool { match self { ClickEvent::Keyboard(_) => false, ClickEvent::Mouse(event) => event.down.first_mouse, + ClickEvent::Touch(_) => false, } } @@ -328,17 +411,19 @@ impl ClickEvent { /// /// `Keyboard`: Always 1 /// `Mouse`: Count of clicks from MouseUpEvent + /// `Touch`: Count of consecutive taps pub fn click_count(&self) -> usize { match self { ClickEvent::Keyboard(_) => 1, ClickEvent::Mouse(event) => event.up.click_count, + ClickEvent::Touch(event) => event.tap_count, } } /// Returns whether the click event is generated by a keyboard event pub fn is_keyboard(&self) -> bool { match self { - ClickEvent::Mouse(_) => false, + ClickEvent::Mouse(_) | ClickEvent::Touch(_) => false, ClickEvent::Keyboard(_) => true, } } @@ -670,6 +755,8 @@ pub enum PlatformInput { Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), + /// A raw touch event on a touch screen. + Touch(TouchEvent), } impl PlatformInput { @@ -686,6 +773,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), + PlatformInput::Touch(_) => None, } } @@ -702,6 +790,15 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => None, PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, + PlatformInput::Touch(_) => None, + } + } + + /// Returns the touch event contained in this input, if any. + pub fn touch_event(&self) -> Option<&TouchEvent> { + match self { + PlatformInput::Touch(event) => Some(event), + _ => None, } } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 5765fd80aff0b5..20600c3eb519ad 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -6,6 +6,9 @@ mod keystroke; #[expect(missing_docs)] pub mod layer_shell; +/// Types for configuring parent-anchored popup windows such as menus, dropdowns and tooltips. +pub mod popup; + #[cfg(any(test, feature = "bench"))] mod bench_dispatcher; @@ -33,21 +36,21 @@ pub(crate) type PlatformScreenCaptureFrame = core_video::image_buffer::CVImageBu use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, - DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, - ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels, - PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, RenderImageParams, - RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, - SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, + DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Edges, Font, FontId, FontMetrics, + FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels, + PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, + SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, }; -use anyhow::Result; #[cfg(any(target_os = "linux", target_os = "freebsd"))] use anyhow::bail; +use anyhow::{Context as _, Result}; use async_task::Runnable; use futures::channel::oneshot; #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; use image::codecs::gif::GifDecoder; -use image::{AnimationDecoder as _, Frame}; +use image::{AnimationDecoder as _, DynamicImage, Frame}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use scheduler::Instant; pub use scheduler::RunnableMeta; @@ -190,6 +193,30 @@ pub trait Platform: 'static { fn on_reopen(&self, callback: Box); fn on_system_wake(&self, callback: Box); + // Mobile platform methods. On mobile the OS owns the application + // lifecycle: apps are backgrounded, foregrounded, and killed at the + // system's discretion, and must react rather than decide. + + /// Registers a callback invoked whenever the application's lifecycle + /// phase changes. See [`AppLifecyclePhase`] for the phase vocabulary and + /// its mapping onto iOS and Android. + /// + /// Desktop platforms never invoke this. + fn on_app_lifecycle(&self, _callback: Box) {} + + /// Registers a callback invoked when the OS signals memory pressure + /// (iOS `didReceiveMemoryWarning`, Android `onTrimMemory`). + /// + /// Desktop platforms never invoke this. + fn on_memory_warning(&self, _callback: Box) {} + + /// The platform's gesture recognition services, if it provides any + /// beyond gpui's portable recognizers. See + /// [`PlatformGestures`](crate::PlatformGestures). + fn gestures(&self) -> Option> { + None + } + fn set_menus(&self, menus: Vec, keymap: &Keymap); fn get_menus(&self) -> Option> { None @@ -212,6 +239,46 @@ pub trait Platform: 'static { fn thermal_state(&self) -> ThermalState; fn on_thermal_state_change(&self, callback: Box); + /// Sets the application's process-wide identity and user-visible name. + /// + /// The identifier is used for platform identity mechanisms such as the + /// Windows AppUserModelID. The name is used wherever the operating system + /// presents the application to the user. Call this once, early in startup, + /// before opening windows or posting notifications. + fn set_app_identity(&self, identifier: &str, name: &str) { + _ = (identifier, name); + } + + /// Posts a notification to the operating system's notification center. + /// + /// Posting a notification whose [`SystemNotification::tag`] matches an + /// earlier one replaces that notification where the platform supports it. + /// No-op on platforms without notification support, or when delivery is + /// unavailable (e.g. authorization was denied). + fn show_system_notification(&self, notification: SystemNotification) { + _ = notification; + } + + /// Removes the delivered or pending notification with this tag. + /// + /// Best-effort: some platforms cannot retract a notification once shown, + /// in which case it ages out of the notification center on its own. + fn dismiss_system_notification(&self, tag: &str) { + _ = tag; + } + + /// Registers the callback invoked when the user activates a system + /// notification, either by clicking its body or one of its action + /// buttons. + /// + /// Implementations must invoke the callback on the main thread. + fn on_system_notification_response( + &self, + callback: Box, + ) { + _ = callback; + } + fn compositor_name(&self) -> &'static str { "" } @@ -282,6 +349,43 @@ pub trait PlatformDisplay: Debug { } } +/// A notification posted to the operating system's notification center, +/// rather than rendered as in-app UI. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SystemNotification { + /// Stable identity for the notification. Posting a new notification with + /// the same tag replaces the previous one where the platform supports it, + /// and responses carry the tag back to the application. + pub tag: SharedString, + /// The notification's headline. + pub title: SharedString, + /// Additional text displayed below the title. + pub body: SharedString, + /// Buttons offered on the notification. Platforms that cannot display + /// action buttons show the notification without them. + pub actions: Vec, +} + +/// A button offered on a [`SystemNotification`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SystemNotificationAction { + /// Identifies the action in [`SystemNotificationResponse::action_id`] + /// when the user presses this button. + pub id: SharedString, + /// The button's user-visible label. + pub label: SharedString, +} + +/// The user's activation of a [`SystemNotification`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SystemNotificationResponse { + /// The [`SystemNotification::tag`] of the activated notification. + pub tag: SharedString, + /// The pressed action button's [`SystemNotificationAction::id`], or + /// `None` when the user activated the notification body itself. + pub action_id: Option, +} + /// Thermal state of the system #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThermalState { @@ -617,6 +721,75 @@ pub struct RequestFrameOptions { pub force_render: bool, } +/// The application's lifecycle phase, as owned and reported by a mobile OS. +/// +/// `Inactive` means visible but not receiving input (a system dialog on +/// top), while `Background` means not visible at all, with process death +/// possible at any time thereafter. +/// +/// | Phase | iOS | Android | +/// |--------------|------------------------------|--------------| +/// | `Active` | `didBecomeActive` | `onResume` | +/// | `Inactive` | `willResignActive` | `onPause` | +/// | `Background` | `didEnterBackground` | `onStop` | +/// | `Foreground` | `willEnterForeground` | `onStart` | +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum AppLifecyclePhase { + /// Foreground and receiving input. + Active, + /// Foreground (visible) but not receiving input. + Inactive, + /// Not visible. The GPU surface may be destroyed while backgrounded and + /// the process may be killed without further notice. + Background, + /// Becoming visible again, before input is restored. + Foreground, +} + +/// Regions of a window that are obscured or reserved by the system. +/// +/// Mobile applications often share space in their window with system-specific +/// geometry, from keyboards to camera notches. In GPUI, all this is abstracted +/// into a single "inset" which should be overlaid on the window's bounds. +/// It is up to the application develop to determine how to handle these cases. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WindowInsets { + /// Regions covered by system UI or hardware: status bar, display + /// cutouts/notch, home indicator, navigation bars. + /// (iOS: `safeAreaInsets`. Android: `WindowInsets` of types + /// `systemBars() | displayCutout()`.) + pub safe_area: Edges, + /// The region covered by the keyboard, when present. + /// (iOS: derived from `keyboardWillShow`/frame-change notifications. + /// Android: `WindowInsets.Type.ime()`.) + pub ime: Edges, +} + +impl WindowInsets { + /// The combined inset content should avoid. + pub fn effective(&self) -> Edges { + Edges { + top: self.safe_area.top.max(self.ime.top), + right: self.safe_area.right.max(self.ime.right), + bottom: self.safe_area.bottom.max(self.ime.bottom), + left: self.safe_area.left.max(self.ime.left), + } + } +} + +/// A change in the state of the focused text input. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TextInputStateChange { + /// An editable element gained focus. + FocusGained, + /// The focused editable element lost focus. + FocusLost, + /// The selection or caret moved + SelectionChanged, + /// The document content changed outside of platform-initiated edits. + ContentChanged, +} + #[expect(missing_docs)] pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn bounds(&self) -> Bounds; @@ -640,6 +813,8 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { answers: &[PromptButton], ) -> Option>; fn activate(&self); + /// Requests that the operating system draw attention to this window. + fn request_attention(&self) {} fn is_active(&self) -> bool; fn is_hovered(&self) -> bool; fn background_appearance(&self) -> WindowBackgroundAppearance; @@ -702,6 +877,10 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn show_window_menu(&self, _position: Point) {} fn start_window_move(&self) {} fn start_window_resize(&self, _edge: ResizeEdge) {} + fn set_exclusive_zone(&self, _zone: Pixels) {} + #[cfg(all(target_os = "linux", feature = "wayland"))] + fn set_exclusive_edge(&self, _edge: layer_shell::Anchor) {} + fn set_input_region(&self, _region: Option<&[Bounds]>) {} fn window_decorations(&self) -> Decorations { Decorations::Server } @@ -717,6 +896,38 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn update_ime_position(&self, _bounds: Bounds); + // Mobile platform methods. + + /// The regions of this window currently obscured or reserved by the + /// system. Zero on platforms without such regions. + fn insets(&self) -> WindowInsets { + WindowInsets::default() + } + + /// Registers a callback invoked whenever [`Self::insets`] change. + /// + /// Contract: fires continuously during animated transitions (Android + /// `WindowInsetsAnimation` progress; on iOS the platform interpolates + /// the keyboard animation curve on frame ticks) and is exact at rest. + fn on_insets_changed(&self, _callback: Box) {} + + /// Sets the handler for the system back action (Android back + /// button/gesture; no source on iOS or desktop). + fn set_back_handler(&self, _callback: Box) {} + + /// Declares whether the application would currently handle the system + /// back action (e.g. navigation depth > 0). + fn set_back_enabled(&self, _enabled: bool) {} + + /// Requests that the soft keyboard be shown. + fn show_soft_keyboard(&self) {} + + /// Requests that the soft keyboard be hidden. + fn hide_soft_keyboard(&self) {} + + /// Inform the operating system that the text input state has changed + fn text_input_state_changed(&self, _change: TextInputStateChange) {} + fn play_system_bell(&self) {} /// Initialize the accessibility adapter with callbacks. @@ -1330,6 +1541,32 @@ impl PlatformInputHandler { .flatten() } + /// See [`InputHandler::set_selected_text_range`]. + pub fn set_selected_text_range(&mut self, range_utf16: Range) { + self.cx + .update(|window, cx| { + self.handler + .set_selected_text_range(range_utf16, window, cx) + }) + .ok(); + } + + /// See [`InputHandler::element_bounds`]. + pub fn element_bounds(&mut self) -> Option> { + self.cx + .update(|window, cx| self.handler.element_bounds(window, cx)) + .ok() + .flatten() + } + + /// See [`InputHandler::text_length_utf16`]. + pub fn text_length_utf16(&mut self) -> Option { + self.cx + .update(|window, cx| self.handler.text_length_utf16(window, cx)) + .ok() + .flatten() + } + #[allow(dead_code)] pub fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { self.handler.accepts_text_input(window, cx) @@ -1448,6 +1685,38 @@ pub trait InputHandler: 'static { cx: &mut App, ) -> Option; + /// Set the range of the user's currently selected text. + /// + /// This is the reverse data-flow direction from [`Self::selected_text_range`]: + /// platforms call it when the system text machinery moves the selection on the + /// application's behalf — e.g. the user drags a system selection handle or + /// invokes Select All from system UI (iOS `UITextInput setSelectedTextRange:`, + /// Android `InputConnection.setSelection`). + /// + /// range_utf16 is in terms of UTF-16 characters, from 0 to the length of the document + fn set_selected_text_range( + &mut self, + _range_utf16: Range, + _window: &mut Window, + _cx: &mut App, + ) { + } + + /// Get the bounds of the focused text element in window coordinates, if known. + /// + /// This is the pull counterpart to the [`PlatformWindow::update_ime_position`] + /// push: mobile platforms ask for the focused element's geometry when they + /// need it (e.g. to frame system text-interaction UI overlaid on the focused + /// element). + fn element_bounds(&mut self, _window: &mut Window, _cx: &mut App) -> Option> { + None + } + + /// Get the length of the document in UTF-16 characters, if known. + fn text_length_utf16(&mut self, _window: &mut Window, _cx: &mut App) -> Option { + None + } + /// Allows a given input context to opt into getting raw key repeats instead of /// sending these to the platform. /// TODO: Ideally we should be able to set ApplePressAndHoldEnabled in NSUserDefaults @@ -1495,14 +1764,24 @@ pub struct WindowOptions { /// The kind of window to create pub kind: WindowKind, - /// Whether the window should be movable by the user. - /// - /// On macOS 27, custom titlebar windows that implement their own drag behavior - /// with [`Window::start_window_move`] should set this to `false`; otherwise - /// AppKit can treat the titlebar region as system-owned and delay clicks - /// while disambiguating titlebar double-clicks. + /// Whether the window can be moved by the user. When `false`, the user cannot drag + /// the window (on macOS this sets `NSWindow.isMovable`, which also disables the + /// Window-menu tiling items); programmatic moves are still allowed. pub is_movable: bool, + /// Whether the application owns dragging of the (custom) titlebar, rather than + /// AppKit. Only has an effect on macOS. + /// + /// Set this to `true` for windows that draw their own titlebar and move the window + /// themselves via [`Window::start_window_move`]. It marks the whole content view as + /// app-owned titlebar content, so AppKit neither drags the window from the titlebar + /// nor delays titlebar clicks while disambiguating double-clicks (a delay first + /// observed on macOS 27). It is independent of `is_movable`, so such windows stay + /// user-movable (via their own drag) and keep the Window-menu tiling items enabled. + /// + /// Leave this `false` for windows that rely on AppKit's native titlebar dragging. + pub app_owns_titlebar_drag: bool, + /// Whether the window should be resizable by the user pub is_resizable: bool, @@ -1558,6 +1837,13 @@ pub struct WindowParams { #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub is_movable: bool, + /// Whether the application owns dragging of the (custom) titlebar (macOS only) + #[cfg_attr( + any(target_os = "linux", target_os = "freebsd", target_os = "windows"), + allow(dead_code) + )] + pub app_owns_titlebar_drag: bool, + /// Whether the window should be resizable by the user #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub is_resizable: bool, @@ -1639,6 +1925,7 @@ impl Default for WindowOptions { show: true, kind: WindowKind::Normal, is_movable: true, + app_owns_titlebar_drag: false, is_resizable: true, is_minimizable: true, display_id: None, @@ -1676,6 +1963,14 @@ pub enum WindowKind { /// use sparingly! PopUp, + /// A parent-anchored, platform-native popup window for menus, comboboxes, context menus and + /// tooltips. Unlike [`WindowKind::PopUp`], it is positioned relative to a parent window. + /// + /// The popup's size comes from [`WindowOptions::window_bounds`], whose origin is ignored. + /// See [`popup::PopupOptions`] for the placement options. Platforms without a native + /// implementation reject it with [`popup::PopupNotSupportedError`]. + AnchoredPopup(popup::PopupOptions), + /// A floating window that appears on top of its parent window Floating, @@ -2104,6 +2399,21 @@ impl ImageFormat { } } + /// Returns the file extension for this image format (without leading dot). + pub const fn extension(self) -> &'static str { + match self { + ImageFormat::Png => "png", + ImageFormat::Jpeg => "jpg", + ImageFormat::Webp => "webp", + ImageFormat::Gif => "gif", + ImageFormat::Svg => "svg", + ImageFormat::Bmp => "bmp", + ImageFormat::Tiff => "tiff", + ImageFormat::Ico => "ico", + ImageFormat::Pnm => "pnm", + } + } + /// Returns the ImageFormat for the given mime type, including known aliases. pub fn from_mime_type(mime_type: &str) -> Option { use strum::IntoEnumIterator; @@ -2135,6 +2445,33 @@ pub struct Image { pub id: u64, } +pub(crate) fn decode_static_image( + bytes: &[u8], + format: image::ImageFormat, +) -> Result> { + let decoder = image::ImageReader::with_format(Cursor::new(bytes), format) + .into_decoder() + .context("creating image decoder")?; + decode_static_image_from_decoder(decoder) +} + +pub(crate) fn decode_static_image_from_decoder( + mut decoder: impl image::ImageDecoder, +) -> Result> { + let orientation = decoder + .orientation() + .context("reading decoder's orientation")?; + let mut image = DynamicImage::from_decoder(decoder).context("decoding image")?; + image.apply_orientation(orientation); + + let mut data = image.into_rgba8(); + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + + Ok(SmallVec::from_elem(Frame::new(data), 1)) +} + impl Hash for Image { fn hash(&self, state: &mut H) { state.write_u64(self.id); @@ -2190,20 +2527,6 @@ impl Image { /// Convert the clipboard image to an `ImageData` object. pub fn to_image_data(&self, svg_renderer: SvgRenderer) -> Result> { - fn frames_for_image( - bytes: &[u8], - format: image::ImageFormat, - ) -> Result> { - let mut data = image::load_from_memory_with_format(bytes, format)?.into_rgba8(); - - // Convert from RGBA to BGRA. - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - - Ok(SmallVec::from_elem(Frame::new(data), 1)) - } - let frames = match self.format { ImageFormat::Gif => { let decoder = GifDecoder::new(Cursor::new(&self.bytes))?; @@ -2230,18 +2553,18 @@ impl Image { frames } - ImageFormat::Png => frames_for_image(&self.bytes, image::ImageFormat::Png)?, - ImageFormat::Jpeg => frames_for_image(&self.bytes, image::ImageFormat::Jpeg)?, - ImageFormat::Webp => frames_for_image(&self.bytes, image::ImageFormat::WebP)?, - ImageFormat::Bmp => frames_for_image(&self.bytes, image::ImageFormat::Bmp)?, - ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?, - ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?, + ImageFormat::Png => decode_static_image(&self.bytes, image::ImageFormat::Png)?, + ImageFormat::Jpeg => decode_static_image(&self.bytes, image::ImageFormat::Jpeg)?, + ImageFormat::Webp => decode_static_image(&self.bytes, image::ImageFormat::WebP)?, + ImageFormat::Bmp => decode_static_image(&self.bytes, image::ImageFormat::Bmp)?, + ImageFormat::Tiff => decode_static_image(&self.bytes, image::ImageFormat::Tiff)?, + ImageFormat::Ico => decode_static_image(&self.bytes, image::ImageFormat::Ico)?, ImageFormat::Svg => { return svg_renderer .render_single_frame(&self.bytes, 1.0) .map_err(Into::into); } - ImageFormat::Pnm => frames_for_image(&self.bytes, image::ImageFormat::Pnm)?, + ImageFormat::Pnm => decode_static_image(&self.bytes, image::ImageFormat::Pnm)?, }; Ok(Arc::new(RenderImage::new(frames))) @@ -2326,6 +2649,22 @@ mod image_tests { use super::*; use std::sync::Arc; + #[test] + fn test_image_to_image_data_applies_exif_orientation() { + let image = Image::from_bytes( + ImageFormat::Jpeg, + include_bytes!("../examples/image/exif-orientation-rotate-180.jpg").to_vec(), + ); + + let render_image = image.to_image_data(SvgRenderer::new(Arc::new(()))).unwrap(); + + assert_eq!(render_image.size(0), size(16.into(), 32.into())); + + let bytes = render_image.as_bytes(0).unwrap(); + assert_eq!(&bytes[..4], &[255, 255, 255, 255]); + assert_eq!(&bytes[(16 * 32 - 1) * 4..], &[0, 0, 0, 255]); + } + #[test] fn test_svg_image_to_image_data_converts_to_bgra() { let image = Image::from_bytes( diff --git a/crates/gpui/src/platform/popup.rs b/crates/gpui/src/platform/popup.rs new file mode 100644 index 00000000000000..a1f8d7ea0d572f --- /dev/null +++ b/crates/gpui/src/platform/popup.rs @@ -0,0 +1,134 @@ +use bitflags::bitflags; +use thiserror::Error; + +use crate::{AnyWindowHandle, Bounds, Pixels, Point}; + +/// Options for a parent-anchored popup window such as a menu, dropdown, context menu or tooltip. +/// +/// A popup is placed relative to an anchor rectangle on its parent window rather than at an +/// absolute screen position. The platform resolves the final position, so this works both on +/// systems where the compositor owns window placement (Wayland) and on platforms with absolute +/// coordinates. +/// +/// The popup's size comes from [`WindowOptions::window_bounds`](crate::WindowOptions), whose +/// origin is ignored. All coordinates are in logical pixels. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PopupOptions { + /// The window the popup is anchored to. + pub parent: AnyWindowHandle, + + /// The rectangle the popup is positioned relative to, in the parent window's coordinate + /// space (the same space element bounds are in). For example, a dropdown menu uses the + /// bounds of the button that opened it. + pub anchor_rect: Bounds, + + /// Which point of [`Self::anchor_rect`] the popup is anchored to. + pub anchor: PopupAnchor, + + /// The direction in which the popup extends away from the anchor point. A dropdown that + /// drops below its button anchors to [`PopupAnchor::BottomLeft`] with a gravity of + /// [`PopupGravity::BottomRight`] so it grows down and to the right. + pub gravity: PopupGravity, + + /// How the platform may adjust the popup if the requested placement would put it off-screen. + pub constraint_adjustment: PopupConstraintAdjustment, + + /// An additional offset applied to the popup after anchoring. + pub offset: Point, + + /// Whether the popup should take an explicit input grab. + /// + /// Grabbing popups behave like menus: they take keyboard focus and are dismissed when the + /// user clicks outside of them or presses a dismissing key. Use it for menus and comboboxes, + /// not for tooltips or other passive popups. + /// + /// A grab must be requested while the triggering input is still active, in practice the + /// press of the mouse button that opens the popup. Open grabbing popups from a mouse-down + /// handler rather than a click handler, otherwise the grab is refused. + /// + /// Automatic dismissal only covers input aimed at other applications. A click elsewhere in + /// your own application still reaches it as usual, so closing the popup in that case is up + /// to you. Nested grabbing popups must be closed in the reverse order they were opened. + pub grab: bool, +} + +/// The point of the anchor rectangle that a popup is anchored to. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupAnchor { + /// Anchor to the center of the anchor rectangle. + #[default] + Center, + /// Anchor to the center of the top edge. + Top, + /// Anchor to the center of the bottom edge. + Bottom, + /// Anchor to the center of the left edge. + Left, + /// Anchor to the center of the right edge. + Right, + /// Anchor to the top-left corner. + TopLeft, + /// Anchor to the bottom-left corner. + BottomLeft, + /// Anchor to the top-right corner. + TopRight, + /// Anchor to the bottom-right corner. + BottomRight, +} + +/// The direction in which a popup extends away from its anchor point. +/// +/// For instance, a gravity of [`PopupGravity::BottomRight`] places the popup below and to the +/// right of the anchor point. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupGravity { + /// The popup is centered over the anchor point. + #[default] + Center, + /// The popup extends upwards from the anchor point. + Top, + /// The popup extends downwards from the anchor point. + Bottom, + /// The popup extends to the left of the anchor point. + Left, + /// The popup extends to the right of the anchor point. + Right, + /// The popup extends up and to the left of the anchor point. + TopLeft, + /// The popup extends down and to the left of the anchor point. + BottomLeft, + /// The popup extends up and to the right of the anchor point. + TopRight, + /// The popup extends down and to the right of the anchor point. + BottomRight, +} + +bitflags! { + /// How a popup may be adjusted by the platform if the requested placement would put it + /// off-screen. If no flags are set, the popup is placed exactly as requested and may be + /// clipped. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct PopupConstraintAdjustment: u32 { + /// The popup may be slid horizontally to stay on-screen. + const SLIDE_X = 1; + /// The popup may be slid vertically to stay on-screen. + const SLIDE_Y = 2; + /// The popup's anchor and gravity may be flipped horizontally to stay on-screen. + const FLIP_X = 4; + /// The popup's anchor and gravity may be flipped vertically to stay on-screen. + const FLIP_Y = 8; + /// The popup may be shrunk horizontally to stay on-screen. + const RESIZE_X = 16; + /// The popup may be shrunk vertically to stay on-screen. + const RESIZE_Y = 32; + } +} + +/// Returned when the current platform has no native popup implementation yet. +/// +/// Native popups are separate from gpui's in-window popovers, which are drawn as elements inside +/// an existing window. A caller that wants a popup on every platform should treat this error as +/// a cue to fall back to that in-window rendering. +#[derive(Debug, Error)] +#[error("popups are not supported on this platform")] +pub struct PopupNotSupportedError; diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index bc1ef4e38b95b6..42edcd6cba3487 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -3,8 +3,8 @@ use crate::{ DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform, PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, - SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, - size, + SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task, + TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -33,6 +33,7 @@ pub(crate) struct TestPlatform { pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, + pub(crate) system_notifications: RefCell, pub text_system: Arc, pub expect_restart: RefCell>>>, headless_renderer_factory: Option Option>>>, @@ -82,6 +83,15 @@ struct TestPrompt { tx: oneshot::Sender, } +#[derive(Default)] +pub(crate) struct TestSystemNotifications { + pub(crate) app_identity: Option<(SharedString, SharedString)>, + pub(crate) shown: Vec, + pub(crate) delivered: Vec, + pub(crate) dismissed: Vec, + response_callback: Option>, +} + #[derive(Default)] pub(crate) struct TestPrompts { multiple_choice: VecDeque, @@ -134,6 +144,7 @@ impl TestPlatform { current_find_pasteboard_item: Mutex::new(None), weak: weak.clone(), opened_url: Default::default(), + system_notifications: Default::default(), text_system, headless_renderer_factory, }) @@ -258,6 +269,40 @@ impl TestPlatform { pub(crate) fn did_prompt_for_new_path(&self) -> bool { !self.prompts.borrow().new_path.is_empty() } + + pub(crate) fn app_identity(&self) -> Option<(SharedString, SharedString)> { + self.system_notifications.borrow().app_identity.clone() + } + + pub(crate) fn shown_system_notifications(&self) -> Vec { + self.system_notifications.borrow().shown.clone() + } + + pub(crate) fn delivered_system_notifications(&self) -> Vec { + self.system_notifications.borrow().delivered.clone() + } + + pub(crate) fn dismissed_system_notifications(&self) -> Vec { + self.system_notifications.borrow().dismissed.clone() + } + + pub(crate) fn simulate_system_notification_response( + &self, + response: SystemNotificationResponse, + ) { + let callback = self + .system_notifications + .borrow_mut() + .response_callback + .take(); + if let Some(mut callback) = callback { + callback(response); + self.system_notifications + .borrow_mut() + .response_callback + .get_or_insert(callback); + } + } } impl Platform for TestPlatform { @@ -416,6 +461,46 @@ impl Platform for TestPlatform { fn on_system_wake(&self, _callback: Box) {} + fn set_app_identity(&self, identifier: &str, name: &str) { + self.system_notifications.borrow_mut().app_identity = + Some((identifier.to_string().into(), name.to_string().into())); + } + + fn show_system_notification(&self, notification: SystemNotification) { + let mut system_notifications = self.system_notifications.borrow_mut(); + if system_notifications.app_identity.is_none() { + return; + } + + let delivered = system_notifications + .delivered + .iter_mut() + .find(|delivered| delivered.tag == notification.tag); + if let Some(delivered) = delivered { + *delivered = notification.clone(); + } else { + system_notifications.delivered.push(notification.clone()); + } + system_notifications.shown.push(notification); + } + + fn dismiss_system_notification(&self, tag: &str) { + let mut system_notifications = self.system_notifications.borrow_mut(); + system_notifications + .delivered + .retain(|notification| notification.tag != tag); + system_notifications + .dismissed + .push(SharedString::from(tag.to_string())); + } + + fn on_system_notification_response( + &self, + callback: Box, + ) { + self.system_notifications.borrow_mut().response_callback = Some(callback); + } + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index bc7f5d79eace55..ea0f5d7e31af43 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -22,6 +22,20 @@ pub type PathVertex_ScaledPixels = PathVertex; #[expect(missing_docs)] pub type DrawOrder = u32; +/// A boolean stored as a `u32` so that GPU-facing structs contain no +/// compiler-inserted padding bytes, which would be undefined behavior to +/// reinterpret as `&[u8]` when writing instance buffers. Guaranteed to be +/// `0` or `1` by construction; shaders read it as a `u32`/`uint`. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +#[repr(transparent)] +pub struct PaddedBool32(u32); + +impl From for PaddedBool32 { + fn from(value: bool) -> Self { + PaddedBool32(value as u32) + } +} + #[derive(Default)] #[expect(missing_docs)] pub struct Scene { @@ -481,6 +495,40 @@ pub enum PrimitiveBatch { Surfaces(Range), } +impl PrimitiveBatch { + #[expect(missing_docs)] + pub fn label(&self) -> String { + match self { + Self::Shadows(range) => format!("shadows ({})", range.len()), + Self::Quads(range) => format!("quads ({})", range.len()), + Self::Paths(range) => format!("paths ({})", range.len()), + Self::Underlines(range) => format!("underlines ({})", range.len()), + Self::MonochromeSprites { texture_id, range } => { + format!( + "monochrome sprites ({}) on atlas {}", + range.len(), + texture_id.index + ) + } + Self::SubpixelSprites { texture_id, range } => { + format!( + "subpixel sprites ({}) on atlas {}", + range.len(), + texture_id.index + ) + } + Self::PolychromeSprites { texture_id, range } => { + format!( + "polychrome sprites ({}) on atlas {}", + range.len(), + texture_id.index + ) + } + Self::Surfaces(range) => format!("surfaces ({})", range.len()), + } + } +} + #[derive(Default, Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] @@ -511,7 +559,7 @@ pub struct Underline { pub content_mask: ContentMask, pub color: Hsla, pub thickness: ScaledPixels, - pub wavy: u32, + pub wavy: PaddedBool32, } impl From for Primitive { @@ -701,7 +749,7 @@ impl From for Primitive { pub struct PolychromeSprite { pub order: DrawOrder, pub pad: u32, - pub grayscale: bool, + pub grayscale: PaddedBool32, pub opacity: f32, pub bounds: Bounds, pub content_mask: ContentMask, diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 184b5f30822c4d..ea2b42fadbd8cb 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -1270,13 +1270,13 @@ pub enum Position { impl From for taffy::style::AlignItems { fn from(value: AlignItems) -> Self { match value { - AlignItems::Start => Self::Start, - AlignItems::End => Self::End, - AlignItems::FlexStart => Self::FlexStart, - AlignItems::FlexEnd => Self::FlexEnd, - AlignItems::Center => Self::Center, - AlignItems::Baseline => Self::Baseline, - AlignItems::Stretch => Self::Stretch, + AlignItems::Start => Self::START, + AlignItems::End => Self::END, + AlignItems::FlexStart => Self::FLEX_START, + AlignItems::FlexEnd => Self::FLEX_END, + AlignItems::Center => Self::CENTER, + AlignItems::Baseline => Self::BASELINE, + AlignItems::Stretch => Self::STRETCH, } } } @@ -1284,15 +1284,15 @@ impl From for taffy::style::AlignItems { impl From for taffy::style::AlignContent { fn from(value: AlignContent) -> Self { match value { - AlignContent::Start => Self::Start, - AlignContent::End => Self::End, - AlignContent::FlexStart => Self::FlexStart, - AlignContent::FlexEnd => Self::FlexEnd, - AlignContent::Center => Self::Center, - AlignContent::Stretch => Self::Stretch, - AlignContent::SpaceBetween => Self::SpaceBetween, - AlignContent::SpaceEvenly => Self::SpaceEvenly, - AlignContent::SpaceAround => Self::SpaceAround, + AlignContent::Start => Self::START, + AlignContent::End => Self::END, + AlignContent::FlexStart => Self::FLEX_START, + AlignContent::FlexEnd => Self::FLEX_END, + AlignContent::Center => Self::CENTER, + AlignContent::Stretch => Self::STRETCH, + AlignContent::SpaceBetween => Self::SPACE_BETWEEN, + AlignContent::SpaceEvenly => Self::SPACE_EVENLY, + AlignContent::SpaceAround => Self::SPACE_AROUND, } } } diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index 5abd3341d22b07..35124b15ef8b4b 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -228,13 +228,30 @@ impl SvgRenderer { } fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { + // Cap the size of the rendered pixmap to avoid texture allocation panics + // Related issue: #56466 + const MAX_SIZE: f32 = 8192.0; + let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?; let svg_size = tree.size(); - let scale = match size { + let mut scale = match size { SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(), SvgSize::ScaleFactor(scale) => scale, }; + let width = svg_size.width() * scale; + if width > MAX_SIZE { + log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})"); + scale *= MAX_SIZE / width; + } + let height = svg_size.height() * scale; + if height > MAX_SIZE { + log::warn!( + "Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})" + ); + scale *= MAX_SIZE / height; + } + // Render the SVG to a pixmap with the specified width and height. let mut pixmap = resvg::tiny_skia::Pixmap::new( (svg_size.width() * scale) as u32, diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs index a2050059634d20..bde651ae5c4572 100644 --- a/crates/gpui/src/tab_stop.rs +++ b/crates/gpui/src/tab_stop.rs @@ -196,6 +196,10 @@ impl TabStopMap { self.insertion_history.len() } + pub(crate) fn tab_stop_count(&self) -> usize { + self.by_id.values().filter(|node| node.tab_stop).count() + } + fn focus_handle_for_order(&self, order: &TabStopNode) -> Option { let handle = self.insertion_history[order.node_insertion_index].focus_handle(); debug_assert!( diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 4844748d6c767d..eb3c391dc5e2b4 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -111,6 +111,36 @@ impl TaffyLayoutEngine { .into() } + /// Treats any `auto` dimension of the given node's style as filling `size`. + /// + /// This is applied to window roots before layout so they behave like the + /// root element on the web, which stretches to fill the initial containing + /// block (the viewport) unless given an explicit size. Explicitly styled + /// dimensions are preserved. + pub fn stretch_auto_size_to_fill( + &mut self, + id: LayoutId, + size: Size, + scale_factor: f32, + ) { + let style = self.taffy.style(id.0).expect(EXPECT_MESSAGE); + let stretch_width = style.size.width.is_auto(); + let stretch_height = style.size.height.is_auto(); + if !stretch_width && !stretch_height { + return; + } + let mut style = style.clone(); + if stretch_width { + style.size.width = + taffy::style::Dimension::length(round_to_device_pixel(size.width.0, scale_factor)); + } + if stretch_height { + style.size.height = + taffy::style::Dimension::length(round_to_device_pixel(size.height.0, scale_factor)); + } + self.taffy.set_style(id.0, style).expect(EXPECT_MESSAGE); + } + // Used to understand performance #[allow(dead_code)] fn count_all_children(&self, parent: LayoutId) -> anyhow::Result { diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 39b87dbb8039e7..f8343be29a09d4 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,36 +1,24 @@ use crate::{ AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId, Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex, - Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity, + Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, WeakEntity, }; use crate::{Empty, Window}; use anyhow::Result; use collections::FxHashSet; use refineable::Refineable; use std::mem; -use std::rc::Rc; use std::{any::TypeId, fmt, ops::Range}; -struct AnyViewState { - prepaint_range: Range, - paint_range: Range, - cache_key: ViewCacheKey, - accessed_entities: FxHashSet, -} - -#[derive(Default)] -struct ViewCacheKey { - bounds: Bounds, - content_mask: ContentMask, - text_style: TextStyle, -} - -/// A dynamically-typed handle to a view, which can be downcast to a [Entity] for a specific type. +/// A dynamically-typed view handle that can be downcast to a specific `Entity`. +/// +/// This is the type-erased counterpart to [`ViewElement`]: it holds an entity plus +/// a function pointer to its render, and is itself a [`View`], so embedding it as an +/// element goes through the same [`ViewElement`] machinery as any other view. #[derive(Clone, Debug)] pub struct AnyView { entity: AnyEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, - cached_style: Option>, } impl From> for AnyView { @@ -38,18 +26,18 @@ impl From> for AnyView { AnyView { entity: value.into_any(), render: any_view::render::, - cached_style: None, } } } impl AnyView { - /// Indicate that this view should be cached when using it as an element. - /// When using this method, the view's previous layout and paint will be recycled from the previous frame if [Context::notify] has not been called since it was rendered. - /// The one exception is when [Window::refresh] is called, in which case caching is ignored. - pub fn cached(mut self, style: StyleRefinement) -> Self { - self.cached_style = Some(style.into()); - self + /// Embed this view as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is recycled from the previous frame unless + /// [Context::notify] was called on the backing entity since it was rendered + /// (or [Window::refresh] is called, which ignores caching). + pub fn cached(self, style: StyleRefinement) -> ViewElement { + ViewElement::new(self).cached(style) } /// Convert this to a weak handle. @@ -68,7 +56,6 @@ impl AnyView { Err(entity) => Err(Self { entity, render: self.render, - cached_style: self.cached_style, }), } } @@ -78,7 +65,7 @@ impl AnyView { self.entity.entity_type } - /// Gets the entity id of this handle. + /// The [`EntityId`] of this view. pub fn entity_id(&self) -> EntityId { self.entity.entity_id() } @@ -92,184 +79,48 @@ impl PartialEq for AnyView { impl Eq for AnyView {} -impl Element for AnyView { - type RequestLayoutState = Option; - type PrepaintState = Option; - - fn id(&self) -> Option { - Some(ElementId::View(self.entity_id())) - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None +/// `AnyView` is the type-erased [`View`]: its `render` is a function pointer rather +/// than a concrete type, but it participates in the reactive graph exactly like any +/// other view via [`ViewElement`]. +impl View for AnyView { + fn entity_id(&self) -> Option { + Some(self.entity.entity_id()) } - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_rendered_view(self.entity_id(), |window| { - // Disable caching when inspecting so that mouse_hit_test has all hitboxes. - let caching_disabled = window.is_inspector_picking(cx); - match self.cached_style.as_ref() { - Some(style) if !caching_disabled => { - let mut root_style = Style::default(); - root_style.refine(style); - let layout_id = window.request_layout(root_style, None, cx); - (layout_id, None) - } - _ => { - let mut element = (self.render)(self, window, cx); - let layout_id = element.request_layout(window, cx); - (layout_id, Some(element)) - } - } - }) - } - - fn prepaint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Option { - window.set_view_id(self.entity_id()); - window.with_rendered_view(self.entity_id(), |window| { - if let Some(mut element) = element.take() { - element.prepaint(window, cx); - return Some(element); - } - - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let content_mask = window.content_mask(); - let text_style = window.text_style(); - - if let Some(mut element_state) = element_state - && element_state.cache_key.bounds == bounds - && element_state.cache_key.content_mask == content_mask - && element_state.cache_key.text_style == text_style - && !window.dirty_views.contains(&self.entity_id()) - && !window.refreshing - { - let prepaint_start = window.prepaint_index(); - window.reuse_prepaint(element_state.prepaint_range.clone()); - cx.entities - .extend_accessed(&element_state.accessed_entities); - let prepaint_end = window.prepaint_index(); - element_state.prepaint_range = prepaint_start..prepaint_end; - - return (None, element_state); - } - - let refreshing = mem::replace(&mut window.refreshing, true); - let prepaint_start = window.prepaint_index(); - let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { - let mut element = (self.render)(self, window, cx); - element.layout_as_root(bounds.size.into(), window, cx); - element.prepaint_at(bounds.origin, window, cx); - element - }); - - let prepaint_end = window.prepaint_index(); - window.refreshing = refreshing; - - ( - Some(element), - AnyViewState { - accessed_entities, - prepaint_range: prepaint_start..prepaint_end, - paint_range: PaintIndex::default()..PaintIndex::default(), - cache_key: ViewCacheKey { - bounds, - content_mask, - text_style, - }, - }, - ) - }, - ) - }) - } - - fn paint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _bounds: Bounds, - _: &mut Self::RequestLayoutState, - element: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - window.with_rendered_view(self.entity_id(), |window| { - let caching_disabled = window.is_inspector_picking(cx); - if self.cached_style.is_some() && !caching_disabled { - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let mut element_state = element_state.unwrap(); - - let paint_start = window.paint_index(); - - if let Some(element) = element { - let refreshing = mem::replace(&mut window.refreshing, true); - element.paint(window, cx); - window.refreshing = refreshing; - } else { - window.reuse_paint(element_state.paint_range.clone()); - } - - let paint_end = window.paint_index(); - element_state.paint_range = paint_start..paint_end; - - ((), element_state) - }, - ) - } else { - element.as_mut().unwrap().paint(window, cx); - } - }); + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + (self.render)(&self, window, cx) } } impl IntoElement for Entity { - type Element = AnyView; + type Element = ViewElement>; fn into_element(self) -> Self::Element { - self.into() + ViewElement::new(self) } } impl IntoElement for AnyView { - type Element = Self; + type Element = ViewElement; fn into_element(self) -> Self::Element { - self + ViewElement::new(self) } } -/// A weak, dynamically-typed view handle that does not prevent the view from being released. +/// A weak, dynamically-typed view handle. pub struct AnyWeakView { entity: AnyWeakEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, } impl AnyWeakView { - /// Convert to a strongly-typed handle if the referenced view has not yet been released. + /// Upgrade to a strong `AnyView` handle, if the view is still alive. pub fn upgrade(&self) -> Option { let entity = self.entity.upgrade()?; Some(AnyView { entity, render: self.render, - cached_style: None, }) } } @@ -306,10 +157,346 @@ mod any_view { cx: &mut App, ) -> AnyElement { let view = view.clone().downcast::().unwrap(); + // Record the view's Render type name so the accessibility debug dump can + // attribute nodes to the view that produced them. + #[cfg(debug_assertions)] + window + .a11y + .view_type_names + .insert(view.entity_id(), std::any::type_name::()); view.update(cx, |view, cx| view.render(window, cx).into_any_element()) } } +/// A renderable that participates in GPUI's reactive graph — the unifying model +/// behind [`Render`] and [`RenderOnce`]. +/// +/// When `entity_id()` returns `Some`, that id becomes the view's identity: it gets +/// a unique element-id space (so internal `use_state` / `.id(..)` never collide +/// across siblings) and `cx.notify()` on that entity re-renders only this view's +/// subtree. `None` behaves like a stateless component. +/// +/// You rarely implement `View` directly. `Entity` and any `T: RenderOnce` +/// get a blanket impl below; implement it by hand only when a component needs both +/// parent-supplied props *and* a backing entity for identity. +pub trait View: 'static + Sized { + /// This view's identity, if it has one. A view typically holds the backing + /// entity as a field and returns its [`EntityId`] here. + /// + /// The id becomes this view's [`ElementId`], so two views keyed on the same + /// entity must not be rendered at the same position in the element tree + /// (e.g. as siblings under the same parent): their internal element state + /// (`use_state`, scroll offsets, etc.) would silently collide. Nesting is + /// fine — the id is scoped by the parent path. + fn entity_id(&self) -> Option; + + /// Render this view into an element tree, consuming `self`. + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; +} + +/// A stateless component (`RenderOnce`) is a `View` with no identity. +impl View for T { + fn entity_id(&self) -> Option { + None + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + RenderOnce::render(self, window, cx) + } +} + +/// An entity that renders itself (`Render`) is a `View` keyed on its own id. +impl View for Entity { + fn entity_id(&self) -> Option { + Some(Entity::entity_id(self)) + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + self.update(cx, |this, cx| { + Render::render(this, window, cx).into_any_element() + }) + } +} + +impl Entity { + /// Embed this entity as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is reused until the entity is notified (or the + /// cached bounds / text style change). Caching requires a definite size: + /// a cached view is laid out from `style` and is *not* measured from its + /// contents. Use [`ViewElement::new`] (or `.child(entity)`) for the + /// uncached case. + #[track_caller] + pub fn cached(self, style: StyleRefinement) -> ViewElement> { + ViewElement::new(self).cached(style) + } +} + +/// The element type for [`View`] implementations. Wraps a `View` and hooks it +/// into layout, prepaint, and paint. Constructed via [`ViewElement::new`]. +#[doc(hidden)] +pub struct ViewElement { + view: Option, + entity_id: Option, + cached_style: Option, + #[cfg(debug_assertions)] + source: &'static core::panic::Location<'static>, +} + +impl ViewElement { + /// Wrap a [`View`] as an element. + #[track_caller] + pub fn new(view: V) -> Self { + let entity_id = view.entity_id(); + ViewElement { + entity_id, + cached_style: None, + view: Some(view), + #[cfg(debug_assertions)] + source: core::panic::Location::caller(), + } + } + + /// Enable caching of this view's rendered subtree, laid out at `style`. + /// The composer supplies the layout style because caching skips rendering + /// the contents to measure them. + /// + /// Crate-private on purpose: caching is only sound for entity-backed views, + /// where [`Context::notify`] is the contract that busts the cache. A stateless + /// view has no such contract, so a frozen subtree could never be invalidated. + /// Reach this through [`Entity::cached`] or [`AnyView::cached`], which are + /// entity-backed by construction. + pub(crate) fn cached(mut self, style: StyleRefinement) -> Self { + self.cached_style = Some(style); + self + } +} + +impl IntoElement for ViewElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +struct ViewElementState { + prepaint_range: Range, + paint_range: Range, + cache_key: ViewElementCacheKey, + accessed_entities: FxHashSet, +} + +struct ViewElementCacheKey { + bounds: Bounds, + content_mask: ContentMask, + text_style: TextStyle, +} + +impl Element for ViewElement { + type RequestLayoutState = Option; + type PrepaintState = Option; + + fn id(&self) -> Option { + self.entity_id.map(ElementId::View) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + #[cfg(debug_assertions)] + return Some(self.source); + + #[cfg(not(debug_assertions))] + return None; + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + if let Some(entity_id) = self.entity_id { + // Stateful path: create a reactive boundary. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + match self.cached_style.as_ref() { + Some(style) if !caching_disabled => { + let mut root_style = Style::default(); + root_style.refine(style); + let layout_id = window.request_layout(root_style, None, cx); + (layout_id, None) + } + _ => { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + } + } + }) + } else { + // Stateless path: isolate subtree via type name (no entity identity). + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + }, + ) + } + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + element: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Option { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.set_view_id(entity_id); + window.with_rendered_view(entity_id, |window| { + if let Some(mut element) = element.take() { + element.prepaint(window, cx); + return Some(element); + } + + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let content_mask = window.content_mask(); + let text_style = window.text_style(); + + if let Some(mut element_state) = element_state + && element_state.cache_key.bounds == bounds + && element_state.cache_key.content_mask == content_mask + && element_state.cache_key.text_style == text_style + && !window.dirty_views.contains(&entity_id) + && !window.refreshing + { + let prepaint_start = window.prepaint_index(); + window.reuse_prepaint(element_state.prepaint_range.clone()); + cx.entities + .extend_accessed(&element_state.accessed_entities); + let prepaint_end = window.prepaint_index(); + element_state.prepaint_range = prepaint_start..prepaint_end; + + return (None, element_state); + } + + let refreshing = mem::replace(&mut window.refreshing, true); + let prepaint_start = window.prepaint_index(); + let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + element.layout_as_root(bounds.size.into(), window, cx); + element.prepaint_at(bounds.origin, window, cx); + element + }); + + let prepaint_end = window.prepaint_index(); + window.refreshing = refreshing; + + ( + Some(element), + ViewElementState { + accessed_entities, + prepaint_range: prepaint_start..prepaint_end, + paint_range: PaintIndex::default()..PaintIndex::default(), + cache_key: ViewElementCacheKey { + bounds, + content_mask, + text_style, + }, + }, + ) + }, + ) + }) + } else { + // Stateless path: just prepaint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().prepaint(window, cx); + }, + ); + Some(element.take().unwrap()) + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + element: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + if self.cached_style.is_some() && !caching_disabled { + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let mut element_state = element_state.unwrap(); + + let paint_start = window.paint_index(); + + if let Some(element) = element { + let refreshing = mem::replace(&mut window.refreshing, true); + element.paint(window, cx); + window.refreshing = refreshing; + } else { + window.reuse_paint(element_state.paint_range.clone()); + } + + let paint_end = window.paint_index(); + element_state.paint_range = paint_start..paint_end; + + ((), element_state) + }, + ) + } else { + element.as_mut().unwrap().paint(window, cx); + } + }); + } else { + // Stateless path: just paint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().paint(window, cx); + }, + ); + } + } +} + /// A view that renders nothing pub struct EmptyView; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4db2d221a823f4..59eb7f8e243f11 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -982,6 +982,7 @@ impl Frame { enum InputModality { Mouse, Keyboard, + Touch, } /// Holds the state for a specific window. @@ -1292,6 +1293,7 @@ impl Window { show, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, display_id, @@ -1320,6 +1322,7 @@ impl Window { titlebar, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, focus, @@ -2077,11 +2080,39 @@ impl Window { self.platform_window.request_decorations(decorations); } + /// Set the exclusive zone for a layer-shell surface: how much screen space it + /// reserves so other surfaces avoid occluding it (e.g. a panel reserving space). + /// Positive values reserve that distance from the anchored edge, 0 lets the + /// surface be moved out of others' exclusive zones, and -1 ignores reserved + /// space and may extend under other surfaces. (Wayland layer-shell windows only) + pub fn set_exclusive_zone(&self, zone: Pixels) { + self.platform_window.set_exclusive_zone(zone); + } + + /// Set which anchored edge a layer-shell surface's exclusive zone applies to. + /// This is only needed to disambiguate a corner-anchored surface; otherwise the + /// edge is deduced from the anchor. The edge must be a single edge the surface + /// is anchored to, or it is ignored. (Wayland layer-shell windows only) + #[cfg(all(target_os = "linux", feature = "wayland"))] + pub fn set_exclusive_edge(&self, edge: crate::layer_shell::Anchor) { + self.platform_window.set_exclusive_edge(edge); + } + /// Start a window resize operation (Wayland) pub fn start_window_resize(&self, edge: ResizeEdge) { self.platform_window.start_window_resize(edge); } + /// Linux (wayland) only: Set the window's input region, the area that receives pointer + /// and touch input. Events outside it pass through to whatever is below the window. + /// + /// - `Some(rects)` restricts input to the union of `rects`, in window coordinates. + /// - `Some(&[])` is an empty region, so the window receives no pointer or touch input. + /// - `None` resets the region to the default, so the whole window receives input again. + pub fn set_input_region(&self, region: Option<&[Bounds]>) { + self.platform_window.set_input_region(region); + } + /// Return the `WindowBounds` to indicate that how a window should be opened /// after it has been closed pub fn window_bounds(&self) -> WindowBounds { @@ -2272,11 +2303,31 @@ impl Window { /// It will cause the window to redraw on the next frame, even if no other changes have occurred. /// /// If called from within a view, it will notify that view on the next frame. Otherwise, it will refresh the entire window. + /// + /// Callers driving purely decorative animations (spinners, pulses, and the + /// like) should prefer [`AnimationExt::with_animation`](crate::AnimationExt::with_animation), + /// which automatically respects [`App::reduce_motion`]. When using this + /// method directly for decorative motion, check [`App::reduce_motion`] + /// and skip the frame request when it is set. pub fn request_animation_frame(&self) { let entity = self.current_view(); self.on_next_frame(move |_, cx| cx.notify(entity)); } + /// Runs all callbacks scheduled via [`Self::on_next_frame`], returning how many ran. + /// + /// Tests have no platform frame loop, so this simulates the delivery of the + /// next frame. + #[cfg(any(test, feature = "test-support"))] + pub fn simulate_next_frame(&mut self, cx: &mut App) -> usize { + let callbacks = self.next_frame_callbacks.take(); + let count = callbacks.len(); + for callback in callbacks { + callback(self, cx); + } + count + } + /// Spawn the future returned by the given closure on the application thread pool. /// The closure is provided a handle to the current window and an `AsyncWindowContext` for /// use within your future. @@ -2323,6 +2374,7 @@ impl Window { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); self.display_id = self.platform_window.display().map(|display| display.id()); + self.mouse_position = self.platform_window.mouse_position(); self.refresh(); @@ -2774,6 +2826,7 @@ impl Window { self.next_frame.clear(); let current_focus_path = self.rendered_frame.focus_path(); let current_window_active = self.rendered_frame.window_active; + let mut focus_before_listeners = self.focus; if previous_focus_path != current_focus_path || previous_window_active != current_window_active @@ -2782,6 +2835,11 @@ impl Window { self.focus_lost_listeners .clone() .retain(&(), |listener| listener(self, cx)); + // The focus-lost fallback (e.g. a workspace refocusing itself) may target + // an element that isn't part of the element tree, in which case scheduling + // a redraw below would dispatch focus-lost again, looping forever. Only + // track focus movement caused by the focus listeners. + focus_before_listeners = self.focus; } let event = WindowFocusEvent { @@ -2806,6 +2864,13 @@ impl Window { self.reset_cursor_style(cx); self.refreshing = false; self.invalidator.set_phase(DrawPhase::None); + // Focus listeners may move focus (e.g. a dock forwarding focus to its active + // panel). `Window::focus` suppresses `refresh` while a draw is in progress, so + // schedule another frame here to render the new focus state and dispatch the + // resulting focus events. + if self.focus != focus_before_listeners { + self.refresh(); + } self.needs_present.set(true); if let Some(draw_start) = draw_started_at { @@ -2897,8 +2962,16 @@ impl Window { } }; - // Layout all root elements. - let mut root_element = self.root.as_ref().unwrap().clone().into_any(); + // Layout all root elements. Like the root element on the web, which + // stretches to fill the viewport unless explicitly sized, window roots + // fill the window when their size is `auto`. + let scale_factor = self.scale_factor(); + let mut root_element = self.root.as_ref().unwrap().clone().into_any_element(); + let root_layout_id = root_element.request_layout(self, cx); + self.layout_engine + .as_mut() + .unwrap() + .stretch_auto_size_to_fill(root_layout_id, root_size, scale_factor); root_element.prepaint_as_root(Point::default(), root_size.into(), self, cx); #[cfg(any(feature = "inspector", debug_assertions))] @@ -2910,12 +2983,17 @@ impl Window { let mut active_drag_element = None; let mut tooltip_element = None; if let Some(prompt) = self.prompt.take() { - let mut element = prompt.view.any_view().into_any(); + let mut element = prompt.view.any_view().into_any_element(); + let prompt_layout_id = element.request_layout(self, cx); + self.layout_engine + .as_mut() + .unwrap() + .stretch_auto_size_to_fill(prompt_layout_id, root_size, scale_factor); element.prepaint_as_root(Point::default(), root_size.into(), self, cx); prompt_element = Some(element); self.prompt = Some(prompt); } else if let Some(active_drag) = cx.active_drag.take() { - let mut element = active_drag.view.clone().into_any(); + let mut element = active_drag.view.clone().into_any_element(); let offset = self.mouse_position() - active_drag.cursor_offset; element.prepaint_as_root(offset, AvailableSpace::min_size(), self, cx); active_drag_element = Some(element); @@ -2954,8 +3032,15 @@ impl Window { let should_send_a11y_update = a11y_active_start_of_frame && a11y_active_end_of_frame; if a11y_active_start_of_frame { + // Harvest frame metadata for the debug dump while the live window + // and frame are still in scope. + let frame_info = crate::window::a11y::debug::FrameDebugInfo { + viewport_size: self.viewport_size, + scale_factor: self.scale_factor, + tab_stop_count: self.next_frame.tab_stops.tab_stop_count(), + }; // clear the builder state regardless - let tree_update = self.a11y.end_frame(); + let tree_update = self.a11y.end_frame(frame_info); if should_send_a11y_update { log::debug!( @@ -2979,7 +3064,7 @@ impl Window { log::error!("Unexpectedly absent TooltipRequest"); continue; }; - let mut element = tooltip_request.tooltip.view.clone().into_any(); + let mut element = tooltip_request.tooltip.view.clone().into_any_element(); let mouse_position = tooltip_request.tooltip.mouse_position; let tooltip_size = element.layout_as_root(AvailableSpace::min_size(), self, cx); @@ -3039,62 +3124,71 @@ impl Window { fn prepaint_deferred_draws(&mut self, cx: &mut App) { assert_eq!(self.element_id_stack.len(), 0); - let mut completed_draws = Vec::new(); - // Process deferred draws in multiple rounds to support nesting. - // Each round processes all current deferred draws, which may produce new ones. + // Each round processes all current deferred draws, which may push new ones. + // + // The draws are processed in place rather than being moved out of + // `next_frame.deferred_draws`: `prepaint_index` snapshots that vector's + // length, so any prepaint range recorded during a round (view caches, + // nested deferred draws) must index the same vector `reuse_prepaint` + // slices on the next frame. Moving the draws out and re-appending them + // shifts the indices of nested draws, causing reused subtrees to graft + // the wrong deferred draws and panic in the dispatch tree. + let mut round_start = 0; let mut depth = 0; loop { + let round_end = self.next_frame.deferred_draws.len(); + if round_start == round_end { + break; + } // Limit maximum nesting depth to prevent infinite loops. assert!(depth < 10, "Exceeded maximum (10) deferred depth"); depth += 1; - let deferred_count = self.next_frame.deferred_draws.len(); - if deferred_count == 0 { - break; - } - // Sort by priority for this round - let traversal_order = self.deferred_draw_traversal_order(); - let mut deferred_draws = mem::take(&mut self.next_frame.deferred_draws); + // Sort this round by priority. + let mut traversal_order = (round_start..round_end).collect::>(); + traversal_order.sort_by_key(|ix| self.next_frame.deferred_draws[*ix].priority); for deferred_draw_ix in traversal_order { - let deferred_draw = &mut deferred_draws[deferred_draw_ix]; - self.element_id_stack - .clone_from(&deferred_draw.element_id_stack); - self.text_style_stack - .clone_from(&deferred_draw.text_style_stack); - self.next_frame - .dispatch_tree - .set_active_node(deferred_draw.parent_node); + let (element, parent_node, current_view, rem_size, absolute_offset, prepaint_range) = { + let deferred_draw = &mut self.next_frame.deferred_draws[deferred_draw_ix]; + self.element_id_stack + .clone_from(&deferred_draw.element_id_stack); + self.text_style_stack + .clone_from(&deferred_draw.text_style_stack); + ( + deferred_draw.element.take(), + deferred_draw.parent_node, + deferred_draw.current_view, + deferred_draw.rem_size, + deferred_draw.absolute_offset, + deferred_draw.prepaint_range.clone(), + ) + }; + self.next_frame.dispatch_tree.set_active_node(parent_node); let prepaint_start = self.prepaint_index(); - if let Some(element) = deferred_draw.element.as_mut() { - self.with_rendered_view(deferred_draw.current_view, |window| { - window.with_rem_size(Some(deferred_draw.rem_size), |window| { - window.with_absolute_element_offset( - deferred_draw.absolute_offset, - |window| { - element.prepaint(window, cx); - }, - ); + if let Some(mut element) = element { + self.with_rendered_view(current_view, |window| { + window.with_rem_size(Some(rem_size), |window| { + window.with_absolute_element_offset(absolute_offset, |window| { + element.prepaint(window, cx); + }); }); - }) + }); + self.next_frame.deferred_draws[deferred_draw_ix].element = Some(element); } else { - self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + self.reuse_prepaint(prepaint_range); } let prepaint_end = self.prepaint_index(); - deferred_draw.prepaint_range = prepaint_start..prepaint_end; + self.next_frame.deferred_draws[deferred_draw_ix].prepaint_range = + prepaint_start..prepaint_end; } - // Save completed draws and continue with newly added ones - completed_draws.append(&mut deferred_draws); - self.element_id_stack.clear(); self.text_style_stack.clear(); + round_start = round_end; } - - // Restore all completed draws - self.next_frame.deferred_draws = completed_draws; } fn paint_deferred_draws(&mut self, cx: &mut App) { @@ -3827,7 +3921,7 @@ impl Window { let opacity = self.element_opacity(); let snapped_bounds = self.snap_bounds(quad.bounds); let snapped_border_widths = self.snap_border_widths(quad.border_widths); - self.next_frame.scene.insert_primitive(Quad { + let quad = Quad { order: 0, bounds: snapped_bounds, content_mask: self.snapped_content_mask(), @@ -3836,7 +3930,82 @@ impl Window { corner_radii: quad.corner_radii.scale(self.scale_factor()), border_widths: snapped_border_widths, border_style: quad.border_style, - }); + }; + + if !quad.background.is_transparent() { + self.next_frame.scene.insert_primitive(quad); + return; + } + + // The strip decomposition below assumes a rectangular clip region; a + // rounded content mask must clip the whole quad at once, so skip the + // optimization in that case. + if quad.content_mask.corner_radii != Corners::default() { + self.next_frame.scene.insert_primitive(quad); + return; + } + + // We're drawing a quad with a border but no fill color. Painting this quad would run the quad shader for every + // transparent interior pixel, which is especially costly when the quad is large. + // Instead, split it into four non-overlapping strips that cover the regions where borders are painted: + // the side strips own the straight left and right edges, while the top and bottom strips own the horizontal + // edges and the rounded corners. + let radii = &quad.corner_radii; + let widths = &quad.border_widths; + + let antialias_slack = point(ScaledPixels(1.0), ScaledPixels(1.0)); + let top_left_inset = point( + widths.left, + widths.top.max(radii.top_left).max(radii.top_right), + ) + antialias_slack; + let bottom_right_inset = point( + widths.right, + widths.bottom.max(radii.bottom_left).max(radii.bottom_right), + ) + antialias_slack; + + let outer_bounds = quad.bounds; + let inner_bounds = Bounds::from_corners( + outer_bounds.origin + top_left_inset, + outer_bounds.bottom_right() - bottom_right_inset, + ); + + if inner_bounds.is_empty() { + self.next_frame.scene.insert_primitive(quad); + return; + } + + let strips = [ + // Top + Bounds::from_corners( + outer_bounds.origin, + point(outer_bounds.right(), inner_bounds.top()), + ), + // Bottom + Bounds::from_corners( + point(outer_bounds.left(), inner_bounds.bottom()), + outer_bounds.bottom_right(), + ), + // Left + Bounds::from_corners( + point(outer_bounds.left(), inner_bounds.top()), + inner_bounds.bottom_left(), + ), + // Right + Bounds::from_corners( + inner_bounds.top_right(), + point(outer_bounds.right(), inner_bounds.bottom()), + ), + ]; + + for strip in strips { + let content_mask_bounds = quad.content_mask.bounds.intersect(&strip); + if !content_mask_bounds.is_empty() { + self.next_frame.scene.insert_primitive(Quad { + content_mask: ContentMask::new(content_mask_bounds), + ..quad + }); + } + } } /// Paint the given `Path` into the scene for the next frame at the current z-index. @@ -3887,7 +4056,7 @@ impl Window { content_mask: self.snapped_content_mask(), color: style.color.unwrap_or_default().opacity(element_opacity), thickness, - wavy: if style.wavy { 1 } else { 0 }, + wavy: style.wavy.into(), }); } @@ -3917,7 +4086,7 @@ impl Window { content_mask: self.snapped_content_mask(), thickness: self.snap_stroke(style.thickness), color: style.color.unwrap_or_default().opacity(opacity), - wavy: 0, + wavy: false.into(), }); } @@ -4077,7 +4246,7 @@ impl Window { self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, - grayscale: false, + grayscale: false.into(), bounds, corner_radii: Default::default(), content_mask, @@ -4192,7 +4361,7 @@ impl Window { self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, - grayscale, + grayscale: grayscale.into(), bounds, content_mask, corner_radii, @@ -4616,6 +4785,7 @@ impl Window { self.last_input_modality = match &event { PlatformInput::KeyDown(_) => InputModality::Keyboard, PlatformInput::MouseMove(_) | PlatformInput::MouseDown(_) => InputModality::Mouse, + PlatformInput::Touch(_) => InputModality::Touch, _ => self.last_input_modality, }; if self.last_input_modality != old_modality { @@ -4709,6 +4879,7 @@ impl Window { PlatformInput::FileDrop(FileDropEvent::Exited) } }, + PlatformInput::Touch(touch) => PlatformInput::Touch(touch), PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; @@ -5249,6 +5420,11 @@ impl Window { self.platform_window.activate(); } + /// Requests that the operating system draw attention to this window. + pub fn request_attention(&self) { + self.platform_window.request_attention(); + } + /// Minimize the current window at the platform level. pub fn minimize_window(&self) { self.platform_window.minimize(); @@ -5328,6 +5504,14 @@ impl Window { receiver } + /// Returns whether a prompt rendered by GPUI is currently active in this window. + /// + /// This is only true for prompts rendered in the window (see + /// [`App::set_prompt_builder`]), not for platform-native prompt dialogs. + pub fn has_active_prompt(&self) -> bool { + self.prompt.is_some() + } + /// Returns the current context stack. pub fn context_stack(&self) -> Vec { let node_id = self.focus_node_id_in_rendered_frame(self.focus); @@ -5598,6 +5782,11 @@ impl Window { self.a11y.is_active() } + /// Debug representation of the last frame's accessibility information. + pub fn debug_a11y_tree_json(&self) -> Option { + self.a11y.debug_tree_json() + } + /// Register a listener for an accessibility action on a specific node. /// The listener will be called when a screen reader requests the given /// action on the node identified by `node_id`. @@ -6387,3 +6576,137 @@ pub fn outline( border_style, } } + +#[cfg(test)] +mod tests { + use crate::{ + AppContext as _, Bounds, Context, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window, canvas, div, px, + size, + }; + use std::{cell::Cell, rc::Rc}; + + struct RootView { + explicit_size: bool, + child_bounds: Rc>>, + } + + impl Render for RootView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let child_bounds = self.child_bounds.clone(); + let root = div().flex().flex_col().child( + canvas( + move |bounds, _, _| child_bounds.set(bounds), + |_, _, _, _| {}, + ) + .size_full(), + ); + if self.explicit_size { + root.w(px(300.)).h(px(200.)) + } else { + root + } + } + } + + #[test] + fn auto_sized_window_root_fills_the_window() { + let mut cx = TestAppContext::single(); + let child_bounds = Rc::new(Cell::new(Bounds::default())); + let window = cx.add_window({ + let child_bounds = child_bounds.clone(); + move |_, _| RootView { + explicit_size: false, + child_bounds, + } + }); + + let viewport_size = cx + .update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + window.viewport_size() + }) + .unwrap(); + + assert_eq!(child_bounds.get().size, viewport_size); + } + + #[test] + fn explicitly_sized_window_root_keeps_its_size() { + let mut cx = TestAppContext::single(); + let child_bounds = Rc::new(Cell::new(Bounds::default())); + let window = cx.add_window({ + let child_bounds = child_bounds.clone(); + move |_, _| RootView { + explicit_size: true, + child_bounds, + } + }); + + cx.update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + assert_eq!(child_bounds.get().size, size(px(300.), px(200.))); + } + + struct FocusForwarder { + a: FocusHandle, + b: FocusHandle, + } + + impl Render for FocusForwarder { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .child(div().w(px(50.)).h(px(50.)).track_focus(&self.a)) + .child(div().w(px(50.)).h(px(50.)).track_focus(&self.b)) + } + } + + /// When a focus listener moves focus again (e.g. a dock forwarding focus to its + /// active panel), the resulting focus events must be dispatched without waiting + /// for an unrelated redraw of the window. + #[gpui::test] + fn test_focus_moved_by_focus_listener_is_dispatched(cx: &mut TestAppContext) { + let b_focus_count = Rc::new(Cell::new(0)); + let window = cx.add_window({ + let b_focus_count = b_focus_count.clone(); + move |window, cx| { + let a = cx.focus_handle(); + let b = cx.focus_handle(); + cx.on_focus(&a, window, |this: &mut FocusForwarder, window, cx| { + let b = this.b.clone(); + window.focus(&b, cx); + }) + .detach(); + cx.on_focus(&b, window, move |_, _, _| { + b_focus_count.set(b_focus_count.get() + 1); + }) + .detach(); + FocusForwarder { a, b } + } + }); + + window + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.executor().run_until_parked(); + + window + .update(cx, |this, window, cx| { + let a = this.a.clone(); + window.focus(&a, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + + window + .update(cx, |this, window, _| { + assert!(this.b.is_focused(window)); + }) + .unwrap(); + assert_eq!(b_focus_count.get(), 1); + } +} diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 68140fd123c271..234e7f0acd9fb8 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -100,6 +100,8 @@ use crate::*; +pub(crate) mod debug; + use crate::{App, Bounds, FocusId, Pixels, SharedString, Window}; use accesskit::{Action, NodeId, TreeUpdate}; use collections::{FxHashMap, FxHashSet}; @@ -150,6 +152,15 @@ pub(crate) struct A11y { /// The window's title, used to label the root node so assistive /// technology can tell windows apart. window_title: Option, + /// The focus id we most recently reported as having no accessibility node, + /// used to log at most once per focus change rather than every frame. + last_focus_without_node: Option, + /// Retains the last tree update (and, in debug builds, per-node provenance) + /// so it can be dumped via [`crate::Window::debug_a11y_tree_json`]. + debug: debug::A11yDebug, + /// Maps a view's [`EntityId`] to its `Render` type name + #[cfg(debug_assertions)] + pub(crate) view_type_names: FxHashMap, } impl A11y { @@ -167,6 +178,26 @@ impl A11y { node_bounds: FxHashMap::default(), action_listeners: FxHashMap::default(), window_title, + last_focus_without_node: None, + debug: debug::A11yDebug::default(), + #[cfg(debug_assertions)] + view_type_names: FxHashMap::default(), + } + } + + /// Logs (once per focus change) that the focused element is not exposed to + /// assistive technology because it has no accessibility node. When this + /// happens, screen readers fall back to announcing the whole window instead + /// of the focused element. The fix is to give the element both an + /// `.id(...)` and a `.role(...)`. + pub(crate) fn note_focus_without_node(&mut self, focus_id: FocusId, reason: &str) { + if self.last_focus_without_node != Some(focus_id) { + self.last_focus_without_node = Some(focus_id); + log::info!( + "a11y: focused element ({focus_id:?}) has no accessibility node \ + ({reason}); assistive technology will announce the whole window \ + instead. Give it both an `.id(...)` and a `.role(...)` to expose it." + ); } } @@ -207,7 +238,16 @@ impl A11y { } } if self.nodes.has_node(node_id) { + // The focused element is properly exposed; reset the dedup so a + // later focus on a node-less element logs again. + self.last_focus_without_node = None; self.nodes.set_focus(node_id); + } else { + // The element registered a focus handle and an id, but never got a + // node because it has no role. + if let Some(focus_id) = self.focus_ids.get(&node_id).copied() { + self.note_focus_without_node(focus_id, "it has an id but no role"); + } } } @@ -236,8 +276,22 @@ impl A11y { } /// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter. - pub(crate) fn end_frame(&mut self) -> TreeUpdate { - self.nodes.finalize() + pub(crate) fn end_frame(&mut self, frame: debug::FrameDebugInfo) -> TreeUpdate { + let update = self.nodes.finalize(); + self.debug.capture( + &update, + self.nodes.focus, + self.nodes.active_descendant, + self.window_title.as_ref(), + frame, + ); + #[cfg(debug_assertions)] + self.debug.capture_node_info(&self.nodes.node_info); + update + } + + pub(crate) fn debug_tree_json(&self) -> Option { + self.debug.to_json() } } @@ -246,11 +300,26 @@ impl A11y { pub struct A11ySubtreeBuilder<'a> { parent_id: NodeId, nodes: &'a mut A11yNodeBuilder, + /// Provenance of the real element whose `a11y_synthetic_children` is + /// running. + #[cfg(debug_assertions)] + creator: debug::NodeCreator, } impl<'a> A11ySubtreeBuilder<'a> { pub(crate) fn new(parent_id: NodeId, nodes: &'a mut A11yNodeBuilder) -> Self { - Self { parent_id, nodes } + Self { + parent_id, + nodes, + #[cfg(debug_assertions)] + creator: debug::NodeCreator::default(), + } + } + + #[cfg(debug_assertions)] + pub(crate) fn with_creator(mut self, creator: debug::NodeCreator) -> Self { + self.creator = creator; + self } /// Derive a [`NodeId`] for a synthetic child. @@ -271,7 +340,20 @@ impl<'a> A11ySubtreeBuilder<'a> { /// Returns `false` if a node with this id is already present in the tree, /// in which case the node is discarded. pub fn push_child(&mut self, id: NodeId, node: accesskit::Node) -> bool { - self.nodes.push_leaf(id, node) + let pushed = self.nodes.push_leaf(id, node); + #[cfg(debug_assertions)] + if pushed { + self.nodes.record_node_info( + id, + debug::NodeDebugInfo { + synthetic: true, + view: self.creator.view, + element_id: self.creator.element_id.clone(), + source_location: self.creator.source_location, + }, + ); + } + pushed } /// A mutable reference to the parent node. @@ -297,6 +379,8 @@ pub(crate) struct A11yNodeBuilder { /// pattern, which allows a focused container to act as if a descendant is /// focused. active_descendant: Option, + #[cfg(debug_assertions)] + node_info: FxHashMap, } impl A11yNodeBuilder { @@ -308,9 +392,17 @@ impl A11yNodeBuilder { seen_ids: FxHashSet::default(), focus: None, active_descendant: None, + #[cfg(debug_assertions)] + node_info: FxHashMap::default(), } } + /// Records provenance for a node already pushed this frame. Debug builds only. + #[cfg(debug_assertions)] + pub(crate) fn record_node_info(&mut self, id: NodeId, info: debug::NodeDebugInfo) { + self.node_info.insert(id, info); + } + #[must_use] fn can_push(&mut self, id: NodeId) -> bool { debug_assert!(!self.ids_stack.is_empty(), "node pushed before push_root"); @@ -380,6 +472,8 @@ impl A11yNodeBuilder { self.ids_stack.clear(); self.nodes_stack.clear(); self.seen_ids.clear(); + #[cfg(debug_assertions)] + self.node_info.clear(); let mut root_node = accesskit::Node::new(accesskit::Role::Window); if let Some(title) = window_title { root_node.set_label(title.to_string()); @@ -788,7 +882,7 @@ mod tests { a11y.nodes.pop(); // c a11y.nodes.pop(); // b - let update = a11y.end_frame(); + let update = a11y.end_frame(Default::default()); assert_eq!(update.focus, a); } } diff --git a/crates/gpui/src/window/a11y/debug.rs b/crates/gpui/src/window/a11y/debug.rs new file mode 100644 index 00000000000000..ae98aa52837b11 --- /dev/null +++ b/crates/gpui/src/window/a11y/debug.rs @@ -0,0 +1,330 @@ +//! Developer tooling for inspecting the accessibility tree. +//! +//! [`A11yDebug`] retains the last [`TreeUpdate`] sent to the platform adapter so +//! it can be serialized on demand (see +//! [`crate::Window::debug_a11y_tree_json`]). In `cfg(debug_assertions)` builds, +//! we capture extra info. + +use accesskit::{Action, NodeId, TreeUpdate}; +use collections::FxHashMap; + +use crate::{Pixels, SharedString, Size}; + +#[derive(Default)] +pub(crate) struct FrameDebugInfo { + pub viewport_size: Size, + pub scale_factor: f32, + pub tab_stop_count: usize, +} + +struct CapturedFrame { + rendered_at: String, + frame_number: u64, + window_title: Option, + node_count: usize, + tab_stop_count: usize, + viewport_size: Size, + scale_factor: f32, +} + +#[cfg(debug_assertions)] +#[derive(Clone, Default)] +pub(crate) struct NodeDebugInfo { + /// Whether the node was synthesized via + /// [`crate::Element::a11y_synthetic_children`] rather than created from a + /// real element with a role and ID. + pub synthetic: bool, + /// The type name of the `Render` view that was rendering when the node was + /// created. + pub view: Option<&'static str>, + /// The [`ElementId`](crate::ElementId) of the creating element (the leaf of + /// its `GlobalElementId`, not the full path). For a synthetic node, this is + /// the real element whose `a11y_synthetic_children` produced it. + pub element_id: Option, + /// Source location where the creating element was constructed. + pub source_location: Option<&'static core::panic::Location<'static>>, +} + +#[cfg(debug_assertions)] +#[derive(Clone, Default)] +pub(crate) struct NodeCreator { + pub view: Option<&'static str>, + pub element_id: Option, + pub source_location: Option<&'static core::panic::Location<'static>>, +} + +#[derive(Default)] +pub(crate) struct A11yDebug { + last_tree_update: Option, + last_gpui_focus: Option, + last_active_descendant: Option, + /// Monotonic counter incremented on each captured frame, so a re-dump makes + /// it obvious whether the tree actually refreshed. + frame_number: u64, + /// Metadata about the most recently captured frame. + last_frame: Option, + #[cfg(debug_assertions)] + last_node_info: FxHashMap, +} + +impl A11yDebug { + pub(crate) fn capture( + &mut self, + update: &TreeUpdate, + gpui_focus: Option, + active_descendant: Option, + window_title: Option<&SharedString>, + frame: FrameDebugInfo, + ) { + self.last_tree_update = Some(update.clone()); + self.last_gpui_focus = gpui_focus; + self.last_active_descendant = active_descendant; + self.frame_number += 1; + self.last_frame = Some(CapturedFrame { + rendered_at: chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false), + frame_number: self.frame_number, + window_title: window_title.cloned(), + node_count: update.nodes.len(), + tab_stop_count: frame.tab_stop_count, + viewport_size: frame.viewport_size, + scale_factor: frame.scale_factor, + }); + } + + #[cfg(debug_assertions)] + pub(crate) fn capture_node_info(&mut self, node_info: &FxHashMap) { + self.last_node_info = node_info.clone(); + } + + /// Serialize the last tree update to a readable JSON string. Node ids are + /// replaced with short ephemeral ids (`a`, `b`, ..., `z`, `aa`, ...). + pub(crate) fn to_json(&self) -> Option { + let update = self.last_tree_update.as_ref()?; + + let mut ephemeral: FxHashMap = FxHashMap::default(); + for (index, (id, _)) in update.nodes.iter().enumerate() { + ephemeral.insert(*id, ephemeral_id(index)); + } + + let mut nodes = serde_json::Map::new(); + for (id, node) in &update.nodes { + let key = ephemeral + .get(id) + .cloned() + .unwrap_or_else(|| id.0.to_string()); + #[cfg(debug_assertions)] + let provenance = self + .last_node_info + .get(id) + .map(|info| NodeProvenance { + element_id: info.element_id.as_deref(), + view: info.view, + source_location: info.source_location.map(|loc| loc.to_string()), + // Only surface synthetic nodes; `false` is the default and + // would just be noise on every real node. + synthetic: info.synthetic.then_some(true), + }) + .unwrap_or_default(); + #[cfg(not(debug_assertions))] + let provenance = NodeProvenance::default(); + let value = node_to_json(*id, node, &ephemeral, &provenance); + nodes.insert(key, value); + } + + let frame = self.last_frame.as_ref().map(|frame| { + serde_json::json!({ + "rendered_at": frame.rendered_at, + "frame_number": frame.frame_number, + "window_title": frame.window_title.as_ref().map(|title| title.to_string()), + "node_count": frame.node_count, + "tab_stop_count": frame.tab_stop_count, + "viewport_size": { + "width": frame.viewport_size.width.0, + "height": frame.viewport_size.height.0, + }, + "scale_factor": frame.scale_factor, + }) + }); + + let root = update + .tree + .as_ref() + .map(|tree| tree.root) + .and_then(|id| ephemeral.get(&id).cloned()); + + let value = serde_json::json!({ + "root": root, + "gpui_focus": self.last_gpui_focus.and_then(|id| ephemeral.get(&id).cloned()), + "active_descendant_focus": self.last_active_descendant.and_then(|id| ephemeral.get(&id).cloned()), + "frame": frame, + "nodes": nodes, + }); + Some(serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())) + } +} + +#[derive(Default)] +struct NodeProvenance<'a> { + element_id: Option<&'a str>, + view: Option<&'a str>, + source_location: Option, + synthetic: Option, +} + +fn node_to_json( + id: NodeId, + node: &accesskit::Node, + ephemeral: &FxHashMap, + provenance: &NodeProvenance, +) -> serde_json::Value { + use serde_json::json; + + let mut map = serde_json::Map::new(); + map.insert("accesskit_id".into(), json!(id.0.to_string())); + + let children: Vec = node + .children() + .iter() + .map(|child| { + ephemeral + .get(child) + .cloned() + .unwrap_or_else(|| child.0.to_string()) + }) + .collect(); + if !children.is_empty() { + map.insert("children".into(), json!(children)); + } + + // Provenance (debug builds only), ordered before the accessibility section. + if let Some(element_id) = provenance.element_id { + map.insert("element_id".into(), json!(element_id)); + } + if let Some(view) = provenance.view { + map.insert("view".into(), json!(view)); + } + if let Some(source_location) = &provenance.source_location { + map.insert("source_location".into(), json!(source_location)); + } + if let Some(synthetic) = provenance.synthetic { + map.insert("synthetic".into(), json!(synthetic)); + } + + // Accessibility semantics for this node, grouped together. + let mut aria = serde_json::Map::new(); + aria.insert("role".into(), json!(format!("{:?}", node.role()))); + + // Which action types the node supports. AccessKit keeps these in a private + // bitset with no getter or iterator, so we probe each variant. `Action::n` + // (from AccessKit's `enumn` feature) maps a discriminant to its variant, + // returning `None` past the last one - so this can't drift out of sync with + // AccessKit's `Action` enum the way a hand-maintained list would. + let mut next_action = 0u8; + let on_action: Vec = std::iter::from_fn(move || { + let action = Action::n(next_action)?; + next_action += 1; + Some(action) + }) + .filter(|action| node.supports_action(*action)) + .map(|action| format!("{action:?}")) + .collect(); + if !on_action.is_empty() { + aria.insert("on_action".into(), json!(on_action)); + } + + // String properties. + if let Some(v) = node.label() { + aria.insert("label".into(), json!(v)); + } + if let Some(v) = node.description() { + aria.insert("description".into(), json!(v)); + } + if let Some(v) = node.value() { + aria.insert("value".into(), json!(v)); + } + if let Some(v) = node.keyboard_shortcut() { + aria.insert("keyboard_shortcut".into(), json!(v)); + } + if let Some(v) = node.access_key() { + aria.insert("access_key".into(), json!(v)); + } + if let Some(v) = node.placeholder() { + aria.insert("placeholder".into(), json!(v)); + } + if let Some(v) = node.tooltip() { + aria.insert("tooltip".into(), json!(v)); + } + if let Some(v) = node.role_description() { + aria.insert("role_description".into(), json!(v)); + } + + // Boolean / enum states. + if let Some(v) = node.is_selected() { + aria.insert("selected".into(), json!(v)); + } + if let Some(v) = node.is_expanded() { + aria.insert("expanded".into(), json!(v)); + } + if let Some(v) = node.toggled() { + aria.insert("toggled".into(), json!(format!("{v:?}"))); + } + if let Some(v) = node.orientation() { + aria.insert("orientation".into(), json!(format!("{v:?}"))); + } + + // Numeric properties. + if let Some(v) = node.numeric_value() { + aria.insert("numeric_value".into(), json!(v)); + } + if let Some(v) = node.min_numeric_value() { + aria.insert("min_numeric_value".into(), json!(v)); + } + if let Some(v) = node.max_numeric_value() { + aria.insert("max_numeric_value".into(), json!(v)); + } + if let Some(v) = node.numeric_value_step() { + aria.insert("numeric_value_step".into(), json!(v)); + } + + // Set / table properties. + if let Some(v) = node.level() { + aria.insert("level".into(), json!(v)); + } + if let Some(v) = node.position_in_set() { + aria.insert("position_in_set".into(), json!(v)); + } + if let Some(v) = node.size_of_set() { + aria.insert("size_of_set".into(), json!(v)); + } + if let Some(v) = node.row_index() { + aria.insert("row_index".into(), json!(v)); + } + if let Some(v) = node.column_index() { + aria.insert("column_index".into(), json!(v)); + } + if let Some(v) = node.row_count() { + aria.insert("row_count".into(), json!(v)); + } + if let Some(v) = node.column_count() { + aria.insert("column_count".into(), json!(v)); + } + + map.insert("aria".into(), serde_json::Value::Object(aria)); + + serde_json::Value::Object(map) +} + +/// Maps a 0-based index to a short id in the sequence `a, b, ..., z, aa, ab, +/// ...` (bijective base-26). +fn ephemeral_id(mut index: usize) -> String { + let mut bytes = Vec::new(); + loop { + bytes.push(b'a' + (index % 26) as u8); + if index < 26 { + break; + } + index = index / 26 - 1; + } + bytes.reverse(); + String::from_utf8(bytes).unwrap_or_default() +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index a1e0531f3d5185..44884414c0c0b6 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -27,7 +27,7 @@ wayland = [ "wayland-protocols-plasma", "wayland-protocols-wlr", "filedescriptor", - "xkbcommon", + "xkbcommon/wayland", "open", "gpui/wayland", ] @@ -37,7 +37,7 @@ x11 = [ "as-raw-xcb-connection", "x11rb", - "xkbcommon", + "xkbcommon/x11", "xim", "x11-clipboard", "filedescriptor", @@ -81,7 +81,8 @@ oo7 = { version = "0.6", default-features = false, features = [ "native_crypto", ] } calloop = "0.14.3" -raw-window-handle = "0.6" +notify-rust.workspace = true +raw-window-handle.workspace = true # Used in both windowing options ashpd = { workspace = true, optional = true } @@ -89,14 +90,14 @@ swash = { version = "0.2.6" } bitflags = { workspace = true, optional = true } filedescriptor = { version = "0.8.2", optional = true } open = { version = "5.2.0", optional = true } -xkbcommon = { version = "0.8.0", features = ["wayland", "x11"], optional = true } +xkbcommon = { version = "0.8.0", default-features = false, optional = true } # Screen capture scap = { workspace = true, optional = true } # Wayland calloop-wayland-source = { version = "0.4.1", optional = true } -wayland-backend = { version = "0.3.3", features = [ +wayland-backend = { version = "0.3.15", features = [ "client_system", "dlopen", ], optional = true } diff --git a/crates/gpui_linux/src/linux.rs b/crates/gpui_linux/src/linux.rs index 7202248e376458..33393f7e9ee41e 100644 --- a/crates/gpui_linux/src/linux.rs +++ b/crates/gpui_linux/src/linux.rs @@ -2,6 +2,7 @@ mod dispatcher; mod headless; mod keyboard; mod platform; +mod system_notifications; #[cfg(any(feature = "wayland", feature = "x11"))] mod text_system; #[cfg(feature = "wayland")] diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs index 2a178521edc04a..fd9caf89fc1528 100644 --- a/crates/gpui_linux/src/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -127,9 +127,12 @@ impl PlatformDispatcher for LinuxDispatcher { } fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { - self.timer_sender - .send(TimerAfter { duration, runnable }) - .ok(); + if let Err(err) = self.timer_sender.send(TimerAfter { duration, runnable }) { + // The timer thread has shut down. Dropping a scheduled runnable cancels its task + // and makes the next poll of any awaiter panic. Leaking leaves the task pending, + // which is acceptable during shutdown. + std::mem::forget(err); + } } fn spawn_realtime(&self, f: Box) { diff --git a/crates/gpui_linux/src/linux/headless.rs b/crates/gpui_linux/src/linux/headless.rs index 2237aeb1941a32..6667a3b5a1ffc9 100644 --- a/crates/gpui_linux/src/linux/headless.rs +++ b/crates/gpui_linux/src/linux/headless.rs @@ -1,3 +1,4 @@ mod client; +mod window; pub(crate) use client::*; diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index e127da1f2f24b1..06da3cbd945b96 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use calloop::{EventLoop, LoopHandle}; use gpui_util::ResultExt; +use crate::linux::headless::window::{HeadlessDisplay, HeadlessWindow}; use crate::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout}; use gpui::{ AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout, @@ -14,6 +15,7 @@ pub struct HeadlessClientState { pub(crate) _loop_handle: LoopHandle<'static, HeadlessClient>, pub(crate) event_loop: Option>, pub(crate) common: LinuxCommon, + pub(crate) display: Rc, } #[derive(Clone)] @@ -47,6 +49,7 @@ impl HeadlessClient { event_loop: Some(event_loop), _loop_handle: handle, common, + display: Rc::new(HeadlessDisplay::new()), }))) } } @@ -61,15 +64,16 @@ impl LinuxClient for HeadlessClient { } fn displays(&self) -> Vec> { - vec![] + vec![self.0.borrow().display.clone()] } fn primary_display(&self) -> Option> { - None + Some(self.0.borrow().display.clone()) } - fn display(&self, _id: DisplayId) -> Option> { - None + fn display(&self, id: DisplayId) -> Option> { + let display = self.0.borrow().display.clone(); + (display.id() == id).then_some(display) } #[cfg(feature = "screen-capture")] @@ -96,9 +100,12 @@ impl LinuxClient for HeadlessClient { fn open_window( &self, _handle: AnyWindowHandle, - _params: WindowParams, + params: WindowParams, ) -> anyhow::Result> { - anyhow::bail!("neither DISPLAY nor WAYLAND_DISPLAY is set. You can run in headless mode"); + Ok(Box::new(HeadlessWindow::new( + params, + self.0.borrow().display.clone(), + ))) } fn compositor_name(&self) -> &'static str { diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs new file mode 100644 index 00000000000000..e41b09723dedaf --- /dev/null +++ b/crates/gpui_linux/src/linux/headless/window.rs @@ -0,0 +1,287 @@ +//! Windows for the headless platform client. +//! +//! A headless window has no compositor surface and no GPU: layout, text +//! shaping, and entity plumbing run normally, `draw` discards the scene, and +//! the sprite atlas hands out tiles without uploading pixels (mirroring +//! GPUI's `TestWindow`/`TestAtlas`). This lets command-line tools drive real +//! `Window`-based code paths without a display server. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use collections::HashMap; +use parking_lot::Mutex; +use uuid::Uuid; + +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult, + DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowParams, px, +}; + +#[derive(Debug)] +pub(crate) struct HeadlessDisplay { + bounds: Bounds, +} + +impl HeadlessDisplay { + pub(crate) fn new() -> Self { + Self { + bounds: Bounds::from_corners(Point::default(), Point::new(px(1920.), px(1080.))), + } + } +} + +impl PlatformDisplay for HeadlessDisplay { + fn id(&self) -> DisplayId { + DisplayId::new(0) + } + + fn uuid(&self) -> anyhow::Result { + // Stable identity: there is exactly one headless display. + Ok(Uuid::nil()) + } + + fn bounds(&self) -> Bounds { + self.bounds + } +} + +struct HeadlessWindowState { + bounds: Bounds, + display: Rc, + input_handler: Option, + title: Option, + is_fullscreen: bool, +} + +pub(crate) struct HeadlessWindow(Rc>); + +impl raw_window_handle::HasWindowHandle for HeadlessWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + // Headless windows are not backed by a native window. + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl raw_window_handle::HasDisplayHandle for HeadlessWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl HeadlessWindow { + pub(crate) fn new(params: WindowParams, display: Rc) -> Self { + Self(Rc::new(RefCell::new(HeadlessWindowState { + bounds: params.bounds, + display, + input_handler: None, + title: None, + is_fullscreen: false, + }))) + } +} + +impl PlatformWindow for HeadlessWindow { + fn bounds(&self) -> Bounds { + self.0.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + + fn content_size(&self) -> Size { + self.bounds().size + } + + fn resize(&mut self, size: Size) { + self.0.borrow_mut().bounds.size = size; + } + + fn scale_factor(&self) -> f32 { + 1.0 + } + + fn appearance(&self) -> WindowAppearance { + WindowAppearance::Dark + } + + fn display(&self) -> Option> { + Some(self.0.borrow().display.clone()) + } + + fn mouse_position(&self) -> Point { + Point::default() + } + + fn modifiers(&self) -> Modifiers { + Modifiers::default() + } + + fn capslock(&self) -> Capslock { + Capslock::default() + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.0.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.0.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + // Fall back to GPUI's rendered prompts. + None + } + + fn activate(&self) {} + + fn is_active(&self) -> bool { + false + } + + fn is_hovered(&self) -> bool { + false + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn set_title(&mut self, title: &str) { + self.0.borrow_mut().title = Some(title.to_owned()); + } + + fn get_title(&self) -> String { + self.0.borrow().title.clone().unwrap_or_default() + } + + fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {} + + fn minimize(&self) {} + + fn zoom(&self) {} + + fn toggle_fullscreen(&self) { + let mut state = self.0.borrow_mut(); + state.is_fullscreen = !state.is_fullscreen; + } + + fn is_fullscreen(&self) -> bool { + self.0.borrow().is_fullscreen + } + + // No compositor drives a frame loop, so frame and status callbacks are + // dropped: anything that awaits a frame will never resolve headlessly. + fn on_request_frame(&self, _callback: Box) {} + + fn on_input(&self, _callback: Box DispatchEventResult>) {} + + fn on_active_status_change(&self, _callback: Box) {} + + fn on_hover_status_change(&self, _callback: Box) {} + + fn on_resize(&self, _callback: Box, f32)>) {} + + fn on_moved(&self, _callback: Box) {} + + fn on_should_close(&self, _callback: Box bool>) {} + + fn on_close(&self, _callback: Box) {} + + fn on_hit_test_window_control(&self, _callback: Box Option>) { + } + + fn on_appearance_changed(&self, _callback: Box) {} + + fn draw(&self, _scene: &Scene) {} + + fn sprite_atlas(&self) -> Arc { + Arc::new(HeadlessAtlas::default()) + } + + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + + fn update_ime_position(&self, _bounds: Bounds) {} + + fn gpu_specs(&self) -> Option { + None + } +} + +/// Allocates atlas tiles without uploading pixels, so glyph and sprite +/// painting completes headlessly. +#[derive(Default)] +struct HeadlessAtlas(Mutex); + +#[derive(Default)] +struct HeadlessAtlasState { + next_id: u32, + tiles: HashMap, +} + +impl PlatformAtlas for HeadlessAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() -> anyhow::Result< + Option<(Size, std::borrow::Cow<'a, [u8]>)>, + >, + ) -> anyhow::Result> { + { + let state = self.0.lock(); + if let Some(&tile) = state.tiles.get(key) { + return Ok(Some(tile)); + } + } + + let Some((size, _)) = build()? else { + return Ok(None); + }; + + let mut state = self.0.lock(); + state.next_id += 1; + let texture_id = state.next_id; + state.next_id += 1; + let tile_id = state.next_id; + let tile = AtlasTile { + texture_id: AtlasTextureId { + index: texture_id, + kind: key.texture_kind(), + }, + tile_id: TileId(tile_id), + padding: 0, + bounds: Bounds { + origin: Point::default(), + size, + }, + }; + state.tiles.insert(key.clone(), tile); + Ok(Some(tile)) + } + + fn remove(&self, key: &AtlasKey) { + self.0.lock().tiles.remove(key); + } +} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 8aac55d32184cd..876f931663fa4a 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -123,6 +123,8 @@ pub(crate) struct LinuxCommon { pub(crate) callbacks: PlatformHandlers, pub(crate) signal: LoopSignal, pub(crate) menus: Vec, + app_name: Option, + system_notifications: crate::linux::system_notifications::SystemNotificationState, #[cfg_attr( not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), allow(dead_code) @@ -163,6 +165,9 @@ impl LinuxCommon { callbacks, signal, menus: Vec::new(), + app_name: None, + system_notifications: crate::linux::system_notifications::SystemNotificationState::new( + ), wake_sender, wake_listener_started: false, }; @@ -556,6 +561,34 @@ impl Platform for LinuxPlatform

{ }); } + fn set_app_identity(&self, _identifier: &str, name: &str) { + self.inner + .with_common(|common| common.app_name = Some(name.to_string())); + } + + fn show_system_notification(&self, notification: gpui::SystemNotification) { + self.inner.with_common(|common| { + common + .system_notifications + .show(common.app_name.as_deref(), notification) + }); + } + + fn dismiss_system_notification(&self, tag: &str) { + self.inner + .with_common(|common| common.system_notifications.dismiss(tag)); + } + + fn on_system_notification_response( + &self, + callback: Box, + ) { + self.inner.with_common(|common| { + let executor = common.foreground_executor.clone(); + common.system_notifications.on_response(&executor, callback) + }); + } + fn on_app_menu_action(&self, callback: Box) { self.inner.with_common(|common| { common.callbacks.app_menu_action = Some(callback); diff --git a/crates/gpui_linux/src/linux/system_notifications.rs b/crates/gpui_linux/src/linux/system_notifications.rs new file mode 100644 index 00000000000000..3530c6f3420987 --- /dev/null +++ b/crates/gpui_linux/src/linux/system_notifications.rs @@ -0,0 +1,154 @@ +//! System notifications over the XDG notifications D-Bus interface, via +//! `notify-rust`. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use futures::StreamExt as _; +use futures::channel::mpsc; +use gpui::{ + ForegroundExecutor, SharedString, SystemNotification, SystemNotificationResponse, Task, +}; + +/// The XDG action key invoked when the user activates the notification body +/// rather than a specific action button. +const DEFAULT_ACTION: &str = "default"; + +type ResponseCallback = Rc>>>; + +pub(crate) struct SystemNotificationState { + response_sender: mpsc::UnboundedSender, + response_receiver: Option>, + callback: ResponseCallback, + _response_task: Option>, +} + +impl SystemNotificationState { + pub(crate) fn new() -> Self { + let (response_sender, response_receiver) = mpsc::unbounded(); + Self { + response_sender, + response_receiver: Some(response_receiver), + callback: Rc::new(RefCell::new(None)), + _response_task: None, + } + } + + pub(crate) fn show(&self, app_name: Option<&str>, notification: SystemNotification) { + let mut builder = notify_rust::Notification::new(); + if let Some(app_name) = app_name { + builder.appname(app_name); + } + builder + .summary(¬ification.title) + .body(¬ification.body) + .action(DEFAULT_ACTION, DEFAULT_ACTION); + let mut action_ids = HashMap::new(); + for (index, action) in notification.actions.iter().enumerate() { + let transport_id = format!("gpui-action-{index}"); + builder.action(&transport_id, &action.label); + action_ids.insert(transport_id, action.id.clone()); + } + let built = builder.finalize(); + + let sender = self.response_sender.clone(); + let tag = notification.tag.clone(); + // `show` connects to the session bus and `wait_for_action` blocks + // until the notification closes, so both run off the UI thread. + let spawn_result = std::thread::Builder::new() + .name("system-notification".to_string()) + .spawn(move || match built.show() { + Ok(handle) => handle.wait_for_action(|action| { + let action_id = match action { + // Sent by `notify-rust` when the notification closes + // without being activated. + "__closed" => return, + transport_id => { + let Some(action_id) = response_action_id(transport_id, &action_ids) + else { + log::warn!( + "system notification returned unknown action {transport_id:?}" + ); + return; + }; + action_id + } + }; + sender + .unbounded_send(SystemNotificationResponse { tag, action_id }) + .ok(); + }), + Err(error) => log::warn!("failed to show system notification: {error}"), + }); + if let Err(error) = spawn_result { + log::warn!("failed to spawn system notification thread: {error}"); + } + } + + pub(crate) fn dismiss(&self, _tag: &str) { + // The XDG notifications protocol only allows closing via the + // server-assigned id on a live handle, which `wait_for_action` + // consumes; stale notifications simply age out of the shade. + } + + pub(crate) fn on_response( + &mut self, + executor: &ForegroundExecutor, + callback: Box, + ) { + *self.callback.borrow_mut() = Some(callback); + + // Responses arrive from per-notification threads; this task hands + // them to the registered callback on the main thread. + if let Some(mut receiver) = self.response_receiver.take() { + let callback = self.callback.clone(); + self._response_task = Some(executor.spawn(async move { + while let Some(response) = receiver.next().await { + // Take the callback out for the call: it may re-enter the + // platform or replace itself. + let taken = callback.borrow_mut().take(); + if let Some(mut taken) = taken { + taken(response); + callback.borrow_mut().get_or_insert(taken); + } + } + })); + } + } +} + +fn response_action_id( + transport_id: &str, + action_ids: &HashMap, +) -> Option> { + if transport_id == DEFAULT_ACTION { + Some(None) + } else { + action_ids.get(transport_id).cloned().map(Some) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caller_action_ids_do_not_collide_with_transport_action_ids() { + let action_ids = HashMap::from([ + ("gpui-action-0".to_string(), SharedString::from("default")), + ("gpui-action-1".to_string(), SharedString::from("__closed")), + ]); + + assert_eq!(response_action_id(DEFAULT_ACTION, &action_ids), Some(None)); + assert_eq!( + response_action_id("gpui-action-0", &action_ids), + Some(Some("default".into())) + ); + assert_eq!( + response_action_id("gpui-action-1", &action_ids), + Some(Some("__closed".into())) + ); + assert_eq!(response_action_id("unknown", &action_ids), None); + } +} diff --git a/crates/gpui_linux/src/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs index 3e90688d1bd98b..cbc962bcffe742 100644 --- a/crates/gpui_linux/src/linux/wayland.rs +++ b/crates/gpui_linux/src/linux/wayland.rs @@ -2,6 +2,7 @@ mod client; mod clipboard; mod cursor; mod display; +mod popup; mod serial; mod window; diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index ac637d3fc46876..fdc40a3de87e66 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -57,7 +57,9 @@ use wayland_protocols::xdg::activation::v1::client::{xdg_activation_token_v1, xd use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; -use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::xdg::shell::client::{ + xdg_popup, xdg_positioner, xdg_surface, xdg_toplevel, xdg_wm_base, +}; use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1; use wayland_protocols::{ wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, @@ -96,7 +98,7 @@ use gpui::{ ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, - ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout, + ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout, WindowKind, WindowParams, point, profiler, px, size, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -110,6 +112,80 @@ const MIN_KEYCODE: u32 = 8; const UNKNOWN_KEYBOARD_LAYOUT_NAME: SharedString = SharedString::new_static("unknown"); const XDG_ACTIVATION_TOKEN_ENV_VAR: &str = "XDG_ACTIVATION_TOKEN"; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ImeCursorRectangle { + x: i32, + y: i32, + width: i32, + height: i32, +} + +impl From> for ImeCursorRectangle { + fn from(bounds: Bounds) -> Self { + Self { + x: bounds.origin.x.as_f32() as i32, + y: bounds.origin.y.as_f32() as i32, + width: bounds.size.width.as_f32() as i32, + height: bounds.size.height.as_f32() as i32, + } + } +} + +trait ImeCursorRectangleSink { + fn set_ime_cursor_rectangle(&self, x: i32, y: i32, width: i32, height: i32); + fn commit_ime_state(&self); +} + +impl ImeCursorRectangleSink for zwp_text_input_v3::ZwpTextInputV3 { + fn set_ime_cursor_rectangle(&self, x: i32, y: i32, width: i32, height: i32) { + self.set_cursor_rectangle(x, y, width, height); + } + + fn commit_ime_state(&self) { + self.commit(); + } +} + +fn set_ime_cursor_rectangle( + text_input: &impl ImeCursorRectangleSink, + cursor_rectangle: ImeCursorRectangle, +) { + text_input.set_ime_cursor_rectangle( + cursor_rectangle.x, + cursor_rectangle.y, + cursor_rectangle.width, + cursor_rectangle.height, + ); +} + +fn update_ime_cursor_rectangle( + text_input: &impl ImeCursorRectangleSink, + last_ime_cursor_rectangle: &mut Option, + bounds: Bounds, +) { + let cursor_rectangle = ImeCursorRectangle::from(bounds); + if *last_ime_cursor_rectangle == Some(cursor_rectangle) { + return; + } + + *last_ime_cursor_rectangle = Some(cursor_rectangle); + set_ime_cursor_rectangle(text_input, cursor_rectangle); + text_input.commit_ime_state(); +} + +fn set_ime_cursor_rectangle_after_done( + text_input: &impl ImeCursorRectangleSink, + last_ime_cursor_rectangle: &mut Option, + bounds: Bounds, + should_commit: bool, +) { + if should_commit { + update_ime_cursor_rectangle(text_input, last_ime_cursor_rectangle, bounds); + } else { + set_ime_cursor_rectangle(text_input, ImeCursorRectangle::from(bounds)); + } +} + fn take_startup_activation_token_from_environment() -> Option { let startup_activation_token = std::env::var(XDG_ACTIVATION_TOKEN_ENV_VAR) .ok() @@ -242,6 +318,7 @@ pub(crate) struct WaylandClientState { pre_edit_text: Option, ime_pre_edit: Option, composing: bool, + last_ime_cursor_rectangle: Option, // Surface to Window mapping windows: HashMap, // Output to scale mapping @@ -347,25 +424,25 @@ impl WaylandClientStatePtr { let client = self.get_client(); let mut state = client.borrow_mut(); state.ime_enabled = Some(true); + state.last_ime_cursor_rectangle = None; let Some(text_input) = state.text_input.take() else { return; }; text_input.enable(); text_input.set_content_type(ContentHint::None, ContentPurpose::Normal); + let mut cursor_rectangle = None; if let Some(window) = state.keyboard_focused_window.clone() { drop(state); if let Some(area) = window.get_ime_area() { - text_input.set_cursor_rectangle( - f32::from(area.origin.x) as i32, - f32::from(area.origin.y) as i32, - f32::from(area.size.width) as i32, - f32::from(area.size.height) as i32, - ); + let area = ImeCursorRectangle::from(area); + set_ime_cursor_rectangle(&text_input, area); + cursor_rectangle = Some(area); } state = client.borrow_mut(); } text_input.commit(); + state.last_ime_cursor_rectangle = cursor_rectangle; state.text_input = Some(text_input); } @@ -387,19 +464,14 @@ impl WaylandClientStatePtr { pub fn update_ime_position(&self, bounds: Bounds) { let client = self.get_client(); - let state = client.borrow_mut(); - if state.composing || state.text_input.is_none() || state.pre_edit_text.is_some() { + let mut state = client.borrow_mut(); + if state.pre_edit_text.is_some() { return; } - - let text_input = state.text_input.as_ref().unwrap(); - text_input.set_cursor_rectangle( - bounds.origin.x.as_f32() as i32, - bounds.origin.y.as_f32() as i32, - bounds.size.width.as_f32() as i32, - bounds.size.height.as_f32() as i32, - ); - text_input.commit(); + let Some(text_input) = state.text_input.clone() else { + return; + }; + update_ime_cursor_rectangle(&text_input, &mut state.last_ime_cursor_rectangle, bounds); } pub fn handle_keyboard_layout_change(&self) { @@ -716,6 +788,7 @@ impl WaylandClient { pre_edit_text: None, ime_pre_edit: None, composing: false, + last_ime_cursor_rectangle: None, outputs: HashMap::default(), in_progress_outputs, wl_outputs, @@ -846,7 +919,29 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); - let parent = state.keyboard_focused_window.clone(); + // Popups name their parent explicitly. Other kinds are parented to the focused window. + let (parent, popup_grab) = match ¶ms.kind { + WindowKind::AnchoredPopup(options) => { + let parent = state + .windows + .values() + .find(|window| window.handle() == options.parent) + .cloned() + .ok_or_else(|| anyhow::anyhow!("popup parent window not found"))?; + // A popup grab must reference a press event or the compositor declines it and + // immediately dismisses the popup, so use the most recent press serial, or no + // grab before any press. + let popup_grab = options.grab.then(|| { + let serial = state + .serial_tracker + .get(SerialKind::MousePress) + .max(state.serial_tracker.get(SerialKind::KeyPress)); + (serial != 0).then(|| (serial, state.wl_seat.clone())) + }); + (Some(parent), popup_grab.flatten()) + } + _ => (state.keyboard_focused_window.clone(), None), + }; let target_output = params.display_id.and_then(|display_id| { let target_protocol_id: u64 = display_id.into(); @@ -859,6 +954,7 @@ impl LinuxClient for WaylandClient { let appearance = state.common.appearance; let compositor_gpu = state.compositor_gpu.take(); + let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), @@ -868,8 +964,10 @@ impl LinuxClient for WaylandClient { params, appearance, parent, + popup_grab, target_output, )?; + if window.0.toplevel().is_some() { state.consume_startup_activation_token(&window.0.surface()); } @@ -1196,6 +1294,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); +delegate_noop!(WaylandClientStatePtr: ignore xdg_positioner::XdgPositioner); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); @@ -1359,6 +1458,31 @@ impl Dispatch for WaylandCl drop(state); let should_close = window.handle_layersurface_event(event); + if should_close { + // Close logic will be handled in drop_window() + window.close(); + } + } +} + +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &xdg_popup::XdgPopup, + event: ::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + + drop(state); + let should_close = window.handle_popup_event(event); + if should_close { // The close logic will be handled in drop_window() window.close(); @@ -1770,15 +1894,13 @@ impl Dispatch for WaylandClientStatePtr { drop(state); window.handle_ime(ImeInput::SetMarkedText(text)); if let Some(area) = window.get_ime_area() { - text_input.set_cursor_rectangle( - f32::from(area.origin.x) as i32, - f32::from(area.origin.y) as i32, - f32::from(area.size.width) as i32, - f32::from(area.size.height) as i32, + let mut state = client.borrow_mut(); + set_ime_cursor_rectangle_after_done( + text_input, + &mut state.last_ime_cursor_rectangle, + area, + last_serial == serial, ); - if last_serial == serial { - text_input.commit(); - } } } else { state.composing = false; @@ -1831,8 +1953,9 @@ impl Dispatch for WaylandClientStatePtr { surface_y, .. } => { + let position = point(px(surface_x as f32), px(surface_y as f32)); state.serial_tracker.update(SerialKind::MouseEnter, serial); - state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); + state.mouse_location = Some(position); state.button_pressed = None; if let Some(window) = get_window(&mut state, &surface.id()) { @@ -1855,8 +1978,16 @@ impl Dispatch for WaylandClientStatePtr { ); } } + let modifiers = state.modifiers; drop(state); window.set_hovered(true); + // No Motion follows Enter unless the pointer keeps moving, so synthesize + // a MouseMove to establish hover at the entry position. + window.handle_input(PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button: None, + modifiers, + })); } } wl_pointer::Event::Leave { .. } => { @@ -1934,7 +2065,11 @@ impl Dispatch for WaylandClientStatePtr { state: WEnum::Value(button_state), .. } => { - state.serial_tracker.update(SerialKind::MousePress, serial); + // Record presses only. Requests referencing this serial (popup grabs, + // interactive moves) are declined when given a release serial. + if button_state == wl_pointer::ButtonState::Pressed { + state.serial_tracker.update(SerialKind::MousePress, serial); + } let button = linux_button_to_gpui(button); let Some(button) = button else { return }; if state.mouse_focused_window.is_none() { @@ -2598,3 +2733,93 @@ impl Dispatch for WaylandClientStatePtr { ) { } } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + #[derive(Default)] + struct FakeImeCursorRectangleSink { + cursor_rectangles: RefCell>, + commit_count: Cell, + } + + impl ImeCursorRectangleSink for FakeImeCursorRectangleSink { + fn set_ime_cursor_rectangle(&self, x: i32, y: i32, width: i32, height: i32) { + self.cursor_rectangles + .borrow_mut() + .push((x, y, width, height)); + } + + fn commit_ime_state(&self) { + self.commit_count.set(self.commit_count.get() + 1); + } + } + + fn ime_cursor_bounds(x: f32) -> Bounds { + Bounds::new(point(px(x), px(20.25)), size(px(1.0), px(18.75))) + } + + #[test] + fn caches_cursor_rectangle_committed_after_done() { + let text_input = FakeImeCursorRectangleSink::default(); + let mut last_ime_cursor_rectangle = None; + let initial_bounds = ime_cursor_bounds(10.0); + let updated_bounds = ime_cursor_bounds(20.0); + + update_ime_cursor_rectangle(&text_input, &mut last_ime_cursor_rectangle, initial_bounds); + set_ime_cursor_rectangle_after_done( + &text_input, + &mut last_ime_cursor_rectangle, + updated_bounds, + true, + ); + update_ime_cursor_rectangle(&text_input, &mut last_ime_cursor_rectangle, updated_bounds); + + assert_eq!(text_input.commit_count.get(), 2); + assert_eq!(text_input.cursor_rectangles.borrow().len(), 2); + } + + #[test] + fn skips_unchanged_cursor_rectangle_after_done() { + let text_input = FakeImeCursorRectangleSink::default(); + let mut last_ime_cursor_rectangle = None; + let bounds = ime_cursor_bounds(10.0); + + update_ime_cursor_rectangle(&text_input, &mut last_ime_cursor_rectangle, bounds); + set_ime_cursor_rectangle_after_done( + &text_input, + &mut last_ime_cursor_rectangle, + bounds, + true, + ); + + assert_eq!(text_input.commit_count.get(), 1); + assert_eq!(text_input.cursor_rectangles.borrow().len(), 1); + } + + #[test] + fn skips_cursor_rectangles_with_unchanged_protocol_coordinates() { + let text_input = FakeImeCursorRectangleSink::default(); + let mut last_ime_cursor_rectangle = None; + + update_ime_cursor_rectangle( + &text_input, + &mut last_ime_cursor_rectangle, + ime_cursor_bounds(10.25), + ); + update_ime_cursor_rectangle( + &text_input, + &mut last_ime_cursor_rectangle, + ime_cursor_bounds(10.75), + ); + + assert_eq!(text_input.commit_count.get(), 1); + assert_eq!( + text_input.cursor_rectangles.borrow().as_slice(), + &[(10, 20, 1, 18)] + ); + } +} diff --git a/crates/gpui_linux/src/linux/wayland/popup.rs b/crates/gpui_linux/src/linux/wayland/popup.rs new file mode 100644 index 00000000000000..4a15e78211b703 --- /dev/null +++ b/crates/gpui_linux/src/linux/wayland/popup.rs @@ -0,0 +1,38 @@ +pub use gpui::popup::*; + +use wayland_protocols::xdg::shell::client::xdg_positioner; + +pub(crate) fn wayland_anchor(anchor: PopupAnchor) -> xdg_positioner::Anchor { + match anchor { + PopupAnchor::Center => xdg_positioner::Anchor::None, + PopupAnchor::Top => xdg_positioner::Anchor::Top, + PopupAnchor::Bottom => xdg_positioner::Anchor::Bottom, + PopupAnchor::Left => xdg_positioner::Anchor::Left, + PopupAnchor::Right => xdg_positioner::Anchor::Right, + PopupAnchor::TopLeft => xdg_positioner::Anchor::TopLeft, + PopupAnchor::BottomLeft => xdg_positioner::Anchor::BottomLeft, + PopupAnchor::TopRight => xdg_positioner::Anchor::TopRight, + PopupAnchor::BottomRight => xdg_positioner::Anchor::BottomRight, + } +} + +pub(crate) fn wayland_gravity(gravity: PopupGravity) -> xdg_positioner::Gravity { + match gravity { + PopupGravity::Center => xdg_positioner::Gravity::None, + PopupGravity::Top => xdg_positioner::Gravity::Top, + PopupGravity::Bottom => xdg_positioner::Gravity::Bottom, + PopupGravity::Left => xdg_positioner::Gravity::Left, + PopupGravity::Right => xdg_positioner::Gravity::Right, + PopupGravity::TopLeft => xdg_positioner::Gravity::TopLeft, + PopupGravity::BottomLeft => xdg_positioner::Gravity::BottomLeft, + PopupGravity::TopRight => xdg_positioner::Gravity::TopRight, + PopupGravity::BottomRight => xdg_positioner::Gravity::BottomRight, + } +} + +pub(crate) fn wayland_constraint_adjustment( + adjustment: PopupConstraintAdjustment, +) -> xdg_positioner::ConstraintAdjustment { + // The flag values match the protocol bitfield, so the bits map across directly. + xdg_positioner::ConstraintAdjustment::from_bits_truncate(adjustment.bits()) +} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index da759cfebc733f..993b2ff3fadc85 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1,12 +1,12 @@ use std::{ - cell::{Ref, RefCell, RefMut}, + cell::{Cell, Ref, RefCell, RefMut}, ffi::c_void, ptr::NonNull, rc::Rc, sync::Arc, }; -use collections::{FxHashSet, HashMap}; +use collections::{FxHashMap, HashMap}; use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; @@ -14,10 +14,12 @@ use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{ Proxy, - protocol::{wl_output, wl_surface}, + protocol::{wl_output, wl_seat, wl_surface}, }; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; +use wayland_protocols::xdg::shell::client::xdg_popup; +use wayland_protocols::xdg::shell::client::xdg_positioner; use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; use wayland_protocols::{ @@ -34,8 +36,10 @@ use gpui::{ PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, - WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, - size, + WindowDecorations, WindowKind, WindowParams, + layer_shell::{Anchor, LayerShellNotSupportedError}, + popup::PopupOptions, + px, size, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu}; @@ -93,7 +97,9 @@ pub struct WaylandWindowState { surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, parent: Option, - children: FxHashSet, + /// Child surfaces mapped to whether they block this window's input (dialogs + /// block, popups don't). Children are closed before this window closes. + children: FxHashMap, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -129,6 +135,7 @@ pub struct WaylandWindowState { pub enum WaylandSurfaceState { Xdg(WaylandXdgSurfaceState), LayerShell(WaylandLayerSurfaceState), + Popup(WaylandPopupSurfaceState), } impl WaylandSurfaceState { @@ -137,6 +144,7 @@ impl WaylandSurfaceState { globals: &Globals, params: &WindowParams, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface @@ -177,12 +185,60 @@ impl WaylandSurfaceState { } if let Some(exclusive_edge) = options.exclusive_edge { - layer_surface - .set_exclusive_edge(super::layer_shell::wayland_anchor(exclusive_edge)); + Self::apply_exclusive_edge(&layer_surface, options.anchor, exclusive_edge); } return Ok(WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, + anchor: options.anchor, + })); + } + + if let WindowKind::AnchoredPopup(options) = ¶ms.kind { + let Some(parent) = parent.as_ref() else { + return Err(anyhow::anyhow!("popup parent window not found")); + }; + + let positioner = build_popup_positioner( + globals, + options, + params.bounds.size, + parent.window_geometry(), + ); + + let xdg_surface = globals + .wm_base + .get_xdg_surface(&surface, &globals.qh, surface.id()); + + // A layer-shell parent takes a null xdg parent and is attached via the layer + // surface. Every other surface kind has an xdg_surface to parent to directly. + let xdg_popup = if let Some(parent_layer_surface) = parent.layer_surface() { + let xdg_popup = xdg_surface.get_popup(None, &positioner, &globals.qh, surface.id()); + parent_layer_surface.get_popup(&xdg_popup); + xdg_popup + } else { + xdg_surface.get_popup( + parent.xdg_surface().as_ref(), + &positioner, + &globals.qh, + surface.id(), + ) + }; + positioner.destroy(); + + if let Some((serial, seat)) = popup_grab { + xdg_popup.grab(&seat, serial); + } + + // Non-blocking: the parent keeps its input so it can dismiss the popup on + // clicks in its own window. + parent.add_child(surface.id(), false); + + return Ok(WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + options: options.clone(), + next_reposition_token: Cell::new(0), })); } @@ -206,7 +262,7 @@ impl WaylandSurfaceState { }); if let Some(parent) = parent.as_ref() { - parent.add_child(surface.id()); + parent.add_child(surface.id(), true); } dialog @@ -244,6 +300,66 @@ pub struct WaylandXdgSurfaceState { pub struct WaylandLayerSurfaceState { layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + anchor: Anchor, +} + +pub struct WaylandPopupSurfaceState { + xdg_surface: xdg_surface::XdgSurface, + xdg_popup: xdg_popup::XdgPopup, + // Kept so the popup can be re-anchored via `xdg_popup.reposition` when resized. + options: PopupOptions, + next_reposition_token: Cell, +} + +fn build_popup_positioner( + globals: &Globals, + options: &PopupOptions, + size: Size, + parent_geometry: Bounds, +) -> xdg_positioner::XdgPositioner { + let positioner = globals.wm_base.create_positioner(&globals.qh, ()); + // A zero or negative size is a protocol error. + positioner.set_size( + f32::from(size.width).max(1.0) as i32, + f32::from(size.height).max(1.0) as i32, + ); + + // The protocol wants the anchor rect relative to the parent's window geometry, while + // `options.anchor_rect` is in gpui window coordinates (surface-local). A rect extending + // outside the geometry or with a zero size is a protocol error, so translate, then clamp + // to at least one pixel inside the geometry, pulling the origin inward at the edges. + let anchor_rect = Bounds { + origin: options.anchor_rect.origin - parent_geometry.origin, + size: options.anchor_rect.size, + }; + let one = Point::new(px(1.0), px(1.0)); + let geometry_bottom_right: Point = parent_geometry.size.into(); + let top_left = anchor_rect + .origin + .min(&(geometry_bottom_right - one)) + .max(&Point::default()); + let bottom_right = anchor_rect + .bottom_right() + .min(&geometry_bottom_right) + .max(&(top_left + one)); + let anchor_rect = Bounds::from_corners(top_left, bottom_right); + positioner.set_anchor_rect( + f32::from(anchor_rect.origin.x) as i32, + f32::from(anchor_rect.origin.y) as i32, + f32::from(anchor_rect.size.width) as i32, + f32::from(anchor_rect.size.height) as i32, + ); + + positioner.set_anchor(super::popup::wayland_anchor(options.anchor)); + positioner.set_gravity(super::popup::wayland_gravity(options.gravity)); + positioner.set_constraint_adjustment(super::popup::wayland_constraint_adjustment( + options.constraint_adjustment, + )); + positioner.set_offset( + f32::from(options.offset.x) as i32, + f32::from(options.offset.y) as i32, + ); + positioner } impl WaylandSurfaceState { @@ -255,6 +371,9 @@ impl WaylandSurfaceState { WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { layer_surface.ack_configure(serial); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.ack_configure(serial); + } } } @@ -274,6 +393,28 @@ impl WaylandSurfaceState { } } + fn xdg_surface(&self) -> Option<&xdg_surface::XdgSurface> { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + Some(xdg_surface) + } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + Some(xdg_surface) + } + WaylandSurfaceState::LayerShell(_) => None, + } + } + + fn layer_surface(&self) -> Option<&zwlr_layer_surface_v1::ZwlrLayerSurfaceV1> { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) = + self + { + Some(layer_surface) + } else { + None + } + } + fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) { match self { WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { @@ -283,6 +424,77 @@ impl WaylandSurfaceState { // cannot set window position of a layer surface layer_surface.set_size(width as u32, height as u32); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.set_window_geometry(x, y, width, height); + } + } + } + + // Re-anchors a mapped popup at a new size via `xdg_popup.reposition`. Repositioning an + // unmapped popup (before the first configure) is a protocol error. + fn reposition_popup( + &self, + globals: &Globals, + size: Size, + parent_geometry: Bounds, + ) { + if let WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_popup, + options, + next_reposition_token, + .. + }) = self + && xdg_popup.version() >= xdg_popup::REQ_REPOSITION_SINCE + { + let token = next_reposition_token.get(); + next_reposition_token.set(token.wrapping_add(1)); + + let positioner = build_popup_positioner(globals, options, size, parent_geometry); + xdg_popup.reposition(&positioner, token); + positioner.destroy(); + } + } + + fn set_exclusive_zone(&self, zone: i32) -> bool { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) = + self + { + layer_surface.set_exclusive_zone(zone); + true + } else { + false + } + } + + /// An exclusive edge must be a single edge that the surface is anchored to, + /// otherwise the compositor raises a fatal `invalid_exclusive_edge` protocol + /// error. An invalid edge is logged and ignored. Returns whether it applied. + fn apply_exclusive_edge( + layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + anchor: Anchor, + edge: Anchor, + ) -> bool { + if edge.bits().count_ones() == 1 && anchor.contains(edge) { + layer_surface.set_exclusive_edge(super::layer_shell::wayland_anchor(edge)); + true + } else { + log::warn!( + "ignoring exclusive edge {edge:?}: must be a single edge of the surface anchor {anchor:?}" + ); + false + } + } + + fn set_exclusive_edge(&self, edge: Anchor) -> bool { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { + layer_surface, + anchor, + .. + }) = self + { + Self::apply_exclusive_edge(layer_surface, *anchor, edge) + } else { + false } } @@ -304,9 +516,18 @@ impl WaylandSurfaceState { toplevel.destroy(); xdg_surface.destroy(); } - WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { layer_surface.destroy(); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + .. + }) => { + // Role object before its xdg_surface, as with the toplevel above. + xdg_popup.destroy(); + xdg_surface.destroy(); + } } } } @@ -374,7 +595,7 @@ impl WaylandWindowState { surface_state, acknowledged_first_configure: false, parent, - children: FxHashSet::default(), + children: FxHashMap::default(), surface, app_id: options.app_id, blur: None, @@ -523,11 +744,18 @@ impl WaylandWindow { params: WindowParams, appearance: WindowAppearance, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = - WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone(), target_output)?; + let surface_state = WaylandSurfaceState::new( + &surface, + &globals, + ¶ms, + parent.clone(), + popup_grab, + target_output, + )?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); @@ -575,18 +803,39 @@ impl WaylandWindowStatePtr { self.state.borrow().surface_state.toplevel().cloned() } + /// The `xdg_surface` backing this window, if it has one. Used to anchor child popups. + pub fn xdg_surface(&self) -> Option { + self.state.borrow().surface_state.xdg_surface().cloned() + } + + /// The layer-shell surface backing this window, if it is one. Used to anchor child popups. + pub fn layer_surface(&self) -> Option { + self.state.borrow().surface_state.layer_surface().cloned() + } + + /// This window's xdg window geometry in surface-local coordinates. Child popup anchor + /// rectangles are relative to it, while gpui coordinates are surface-local. + pub fn window_geometry(&self) -> Bounds { + let state = self.state.borrow(); + inset_by_tiling( + state.bounds.map_origin(|_| px(0.0)), + state.inset(), + state.tiling, + ) + } + pub fn ptr_eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.state, &other.state) } - pub fn add_child(&self, child: ObjectId) { + pub fn add_child(&self, child: ObjectId, blocking: bool) { let mut state = self.state.borrow_mut(); - state.children.insert(child); + state.children.insert(child, blocking); } pub fn is_blocked(&self) -> bool { let state = self.state.borrow(); - !state.children.is_empty() + state.children.values().any(|&blocking| blocking) } pub fn frame(&self) { @@ -889,6 +1138,35 @@ impl WaylandWindowStatePtr { } } + // Returns `true` if the popup should be closed. + pub fn handle_popup_event(&self, event: xdg_popup::Event) -> bool { + match event { + // Only the size is needed, the position is the compositor's. The following + // xdg_surface.configure applies the change. + xdg_popup::Event::Configure { width, height, .. } => { + let size = if width <= 0 || height <= 0 { + None + } else { + Some(size(px(width as f32), px(height as f32))) + }; + + self.state.borrow_mut().in_progress_configure = Some(InProgressConfigure { + size, + fullscreen: false, + maximized: false, + resizing: false, + tiling: Tiling::default(), + }); + + false + } + xdg_popup::Event::PopupDone => true, + // Precedes the reposition's Configure, which does the work. The token is not needed. + xdg_popup::Event::Repositioned { .. } => false, + _ => false, + } + } + #[allow(clippy::mutable_key_type)] pub fn handle_surface_event( &self, @@ -1025,8 +1303,7 @@ impl WaylandWindowStatePtr { pub fn close(&self) { let state = self.state.borrow(); let client = state.client.get_client(); - #[allow(clippy::mutable_key_type)] - let children = state.children.clone(); + let children = state.children.keys().cloned().collect::>(); drop(state); for child in children { @@ -1192,6 +1469,23 @@ impl PlatformWindow for WaylandWindow { let state = self.borrow(); let state_ptr = self.0.clone(); + // A popup's placement is the compositor's, so a resize re-runs the positioner and the + // configure reply drives the buffer resize. Before the first configure the popup is + // unmapped and cannot reposition, but the initial positioner already carries the size. + if matches!(state.surface_state, WaylandSurfaceState::Popup(_)) { + if state.acknowledged_first_configure { + let parent_geometry = state + .parent + .as_ref() + .map(|parent| parent.window_geometry()) + .unwrap_or_default(); + state + .surface_state + .reposition_popup(&state.globals, size, parent_geometry); + } + return; + } + // Keep window geometry consistent with configure handling. On Wayland, window geometry is // surface-local: resizing should not attempt to translate the window; the compositor // controls placement. We also account for client-side decoration insets and tiling. @@ -1291,6 +1585,8 @@ impl PlatformWindow for WaylandWindow { } } + fn request_attention(&self) {} + fn is_active(&self) -> bool { self.borrow().active } @@ -1489,6 +1785,59 @@ impl PlatformWindow for WaylandWindow { } } + fn set_exclusive_zone(&self, zone: Pixels) { + let state = self.borrow(); + if state + .surface_state + .set_exclusive_zone(f32::from(zone) as i32) + { + // Commit to apply it immediately, otherwise it only takes effect + // on the next frame. + state.surface.commit(); + } + } + + fn set_exclusive_edge(&self, edge: Anchor) { + let state = self.borrow(); + if state.surface_state.set_exclusive_edge(edge) { + // Commit to apply it immediately, otherwise it only takes effect + // on the next frame. + state.surface.commit(); + } + } + + fn set_input_region(&self, region: Option<&[Bounds]>) { + let state = self.borrow(); + match region { + // No region means the whole surface receives input. + None => state.surface.set_input_region(None), + // A region restricts input to its rectangles. An empty region + // receives no input at all. + Some(rects) => { + let wl_region = state + .globals + .compositor + .create_region(&state.globals.qh, ()); + for rect in rects { + let rect = rect.map(|pixels| f32::from(pixels) as i32); + wl_region.add( + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height, + ); + } + state.surface.set_input_region(Some(&wl_region)); + wl_region.destroy(); + } + } + + // Commit so the new input region applies immediately. Otherwise it + // waits for the next frame, which could be the very click we want to + // allow passing through. + state.surface.commit(); + } + fn window_decorations(&self) -> Decorations { let state = self.borrow(); match state.decorations { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 147630d054d180..4fde66f06ca858 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -7,7 +7,7 @@ use gpui::{ Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, px, + WindowDecorations, WindowKind, WindowParams, popup::PopupNotSupportedError, px, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; @@ -18,7 +18,7 @@ use x11rb::{ connection::Connection, cookie::{Cookie, VoidCookie}, errors::ConnectionError, - properties::WmSizeHints, + properties::{WmHints, WmSizeHints}, protocol::{ sync, xinput::{self, ConnectionExt as _}, @@ -428,6 +428,12 @@ impl X11WindowState { supports_xinput_gestures: bool, is_bgr: bool, ) -> anyhow::Result { + // Native popups are not implemented on X11 yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(PopupNotSupportedError.into()); + } + let x_screen_index = params .display_id .map_or(x_main_screen_index, |did| u64::from(did) as usize); @@ -1410,7 +1416,11 @@ impl PlatformWindow for X11Window { ) .log_err() .map_or(Point::new(Pixels::ZERO, Pixels::ZERO), |reply| { - Point::new((reply.root_x as u32).into(), (reply.root_y as u32).into()) + let scale_factor = self.0.state.borrow().scale_factor; + Point::new( + px(reply.win_x as f32 / scale_factor), + px(reply.win_y as f32 / scale_factor), + ) }) } @@ -1482,6 +1492,33 @@ impl PlatformWindow for X11Window { xcb_flush(&self.0.xcb); } + fn request_attention(&self) { + if self.is_active() { + return; + } + + let mut hints = WmHints::new(); + match WmHints::get(&*self.0.xcb, self.0.x_window) { + Ok(cookie) => match cookie.reply() { + Ok(Some(existing_hints)) => hints = existing_hints, + Ok(None) => {} + Err(error) => { + log::debug!("failed to read X11 WM_HINTS before setting urgency: {error}") + } + }, + Err(error) => { + log::debug!("failed to request X11 WM_HINTS before setting urgency: {error}") + } + } + hints.urgent = true; + check_reply( + || "X11 ChangeProperty for WM_HINTS urgency failed.", + hints.set(&*self.0.xcb, self.0.x_window), + ) + .log_err(); + xcb_flush(&self.0.xcb); + } + fn is_active(&self) -> bool { self.0.state.borrow().active } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 75ce276bf02faf..610d65a71f4e7c 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -27,6 +27,7 @@ accesskit_macos.workspace = true anyhow.workspace = true async-task = "4.7" block = "0.1" +block2.workspace = true cocoa.workspace = true collections.workspace = true core-foundation.workspace = true @@ -54,9 +55,10 @@ objc.workspace = true objc2.workspace = true objc2-app-kit.workspace = true objc2-foundation.workspace = true +objc2-user-notifications.workspace = true parking_lot.workspace = true pathfinder_geometry = "0.5" -raw-window-handle = "0.6" +raw-window-handle.workspace = true semver.workspace = true smallvec.workspace = true strum.workspace = true diff --git a/crates/gpui_macos/src/display_link.rs b/crates/gpui_macos/src/display_link.rs index 468f3d8bde162e..5387cb6e35d209 100644 --- a/crates/gpui_macos/src/display_link.rs +++ b/crates/gpui_macos/src/display_link.rs @@ -1,38 +1,241 @@ +//! Frame pacing for macOS windows, built on `CVDisplayLink`. +//! +//! CVDisplayLink has no safe teardown: `CVDisplayLinkStop` merely flags the +//! link's io thread to exit "soon" and returns immediately, and there is no +//! way to learn when (or whether) a final output callback has finished. Two +//! crash classes came from ignoring this: +//! +//! * releasing the link after `stop` let the still-running io thread read the +//! freed link's internals (segfault in `CVHWTime::reset`, fixed by leaking +//! the link in #32116); +//! * releasing the dispatch source that the output callback dereferenced via +//! its context pointer let a straggler callback read freed memory (segfault +//! in `dispatch_source_merge_data`, Sentry issue ZED-7XR). +//! +//! Instead of leaking one link and one source per teardown (and teardown +//! happens on every resize tick, activation, and occlusion change), we keep a +//! single immortal `CVDisplayLink` per display in a static registry and have +//! windows subscribe to it: +//! +//! * Links are created lazily and never released, so the release race cannot +//! occur. The output callback's context is the display id (an integer, not +//! a pointer), and the only memory the callback dereferences is the static +//! registry, so a straggler callback after `stop` is a harmless no-op. +//! * Each window owns one dispatch source for the life of the window. On +//! window close it is removed from the registry under the lock (making it +//! unreachable from the callback), cancelled, and genuinely released. +//! * A display's link runs iff it has subscribers; windows never start or +//! stop links directly, so interleaved starts/stops of windows sharing a +//! display cannot conflict. +//! +//! One tradeoff of immortal entries: a link created for a given +//! `CGDirectDisplayID` is reused forever, including after the display is +//! unplugged and one reappears with the same id, or after mode/refresh-rate +//! changes. `CVDisplayLink` looks up display timing dynamically, so a cached +//! link keeps pacing correctly; if that ever proves untrue the fix is to +//! also refresh entries from a display-reconfiguration callback, not to +//! release links (which would reintroduce the teardown race). +//! +//! Lock ordering: the output callback runs on the link's io thread and takes +//! the registry lock, possibly while holding CVDisplayLink-internal locks. To +//! avoid a lock cycle through those (undocumented) internals, we never call a +//! CoreVideo function while holding the registry lock. Registry mutations and +//! link start/stop happen on the main thread only, which keeps the `running` +//! flag and the link's actual state consistent without holding the lock +//! across the calls. +//! +//! `std::sync::Mutex` rather than `parking_lot` is deliberate: on macOS it is +//! currently backed by `os_unfair_lock`, whose priority donation resolves +//! inversions between the high-priority io thread and the main thread. (That +//! is a std implementation detail, not a guarantee; if it changes, the cost +//! is added latency under contention, not incorrectness.) + use anyhow::Result; use core_graphics::display::CGDirectDisplayID; use dispatch2::{ _dispatch_source_type_data_add, DispatchObject, DispatchQueue, DispatchRetained, DispatchSource, }; use gpui_util::ResultExt; -use std::ffi::c_void; +use std::{ + collections::{BTreeMap, btree_map}, + ffi::c_void, + sync::{Mutex, MutexGuard, PoisonError}, +}; -pub struct DisplayLink { - display_link: Option, - frame_requests: DispatchRetained, +static REGISTRY: Mutex = Mutex::new(Registry::new()); + +struct Registry { + displays: BTreeMap, + next_subscriber_id: u64, } -impl DisplayLink { - pub fn new( - display_id: CGDirectDisplayID, - data: *mut c_void, - callback: extern "C" fn(*mut c_void), - ) -> Result { - unsafe extern "C" fn display_link_callback( - _display_link_out: *mut sys::CVDisplayLink, - _current_time: *const sys::CVTimeStamp, - _output_time: *const sys::CVTimeStamp, - _flags_in: i64, - _flags_out: *mut i64, - frame_requests: *mut c_void, - ) -> i32 { - unsafe { - let frame_requests = &*(frame_requests as *const DispatchSource); - frame_requests.merge_data(1); - 0 +impl Registry { + const fn new() -> Self { + Registry { + displays: BTreeMap::new(), + next_subscriber_id: 0, + } + } +} + +struct DisplayEntry { + link: sys::DisplayLink, + running: bool, + subscribers: Vec<(SubscriberId, DispatchRetained)>, +} + +// SAFETY: Both fields wrapping raw pointers are refcounted handles to +// thread-safe objects that are valid on any thread: `sys::DisplayLink` to a +// CoreVideo object, and each subscriber's `DispatchRetained` +// to a GCD object (which the display's io thread really does use, calling +// `merge_data` from the output callback). All mutation of the entry itself +// is serialized by the registry lock. +unsafe impl Send for DisplayEntry {} + +#[derive(Copy, Clone, PartialEq, Eq)] +struct SubscriberId(u64); + +fn lock_registry() -> MutexGuard<'static, Registry> { + // Proceeding past poison is safe here (the map's invariants hold after + // any partial mutation), and panicking instead would abort the process + // when this is reached from the extern "C" output callback. + REGISTRY.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn debug_assert_main_thread() { + #[cfg(debug_assertions)] + { + use objc::{class, msg_send, sel, sel_impl}; + let is_main_thread: objc::runtime::BOOL = + unsafe { msg_send![class!(NSThread), isMainThread] }; + debug_assert!( + is_main_thread == objc::runtime::YES, + "display link registry mutations must happen on the main thread; \ + the registry's lock ordering and state consistency depend on it" + ); + } +} + +unsafe extern "C" fn display_link_output_callback( + _display_link_out: *mut sys::CVDisplayLink, + _current_time: *const sys::CVTimeStamp, + _output_time: *const sys::CVTimeStamp, + _flags_in: i64, + _flags_out: *mut i64, + display_id: *mut c_void, +) -> i32 { + let display_id = display_id as usize as CGDirectDisplayID; + let registry = lock_registry(); + if let Some(entry) = registry.displays.get(&display_id) { + for (_, frame_requests) in &entry.subscribers { + frame_requests.merge_data(1); + } + } + 0 +} + +fn subscribe( + display_id: CGDirectDisplayID, + frame_requests: DispatchRetained, +) -> Result { + debug_assert_main_thread(); + + let needs_link = !lock_registry().displays.contains_key(&display_id); + let new_link = if needs_link { + // Created outside the registry lock; see the lock ordering note above. + Some(unsafe { + sys::DisplayLink::new( + display_id, + display_link_output_callback, + display_id as usize as *mut c_void, + )? + }) + } else { + None + }; + + let (subscriber_id, link_to_start) = { + let mut registry = lock_registry(); + let registry = &mut *registry; + let subscriber_id = SubscriberId(registry.next_subscriber_id); + registry.next_subscriber_id += 1; + let entry = match (registry.displays.entry(display_id), new_link) { + // If an entry appeared since the check above, dropping `new_link` + // is safe: a never-started link has no io thread, so releasing it + // doesn't race. + (btree_map::Entry::Occupied(entry), _) => entry.into_mut(), + (btree_map::Entry::Vacant(vacant), Some(link)) => vacant.insert(DisplayEntry { + link, + running: false, + subscribers: Vec::new(), + }), + (btree_map::Entry::Vacant(_), None) => { + // Entries are never removed, so an entry observed above cannot + // have disappeared while subscriptions stay on the main thread. + anyhow::bail!("display link registry entry vanished for display {display_id}"); + } + }; + entry.subscribers.push((subscriber_id, frame_requests)); + let link_to_start = if entry.running { + None + } else { + entry.running = true; + // Clone the refcounted handle so the CVDisplayLinkStart call can + // happen after the lock is released. + Some(entry.link.clone()) + }; + (subscriber_id, link_to_start) + }; + + if let Some(mut link) = link_to_start { + if let Err(error) = unsafe { link.start() } { + let mut registry = lock_registry(); + if let Some(entry) = registry.displays.get_mut(&display_id) { + entry.running = false; + entry.subscribers.retain(|(id, _)| *id != subscriber_id); } + return Err(error); } + } + + Ok(subscriber_id) +} - unsafe { +fn unsubscribe(display_id: CGDirectDisplayID, subscriber_id: SubscriberId) { + debug_assert_main_thread(); + + let link_to_stop = { + let mut registry = lock_registry(); + let Some(entry) = registry.displays.get_mut(&display_id) else { + return; + }; + entry.subscribers.retain(|(id, _)| *id != subscriber_id); + if entry.subscribers.is_empty() && entry.running { + entry.running = false; + Some(entry.link.clone()) + } else { + None + } + }; + + if let Some(mut link) = link_to_stop { + // A final output callback can still fire after this returns; it finds + // no subscribers for this display and does nothing. + unsafe { link.stop().log_err() }; + } +} + +/// A per-window source of frame requests, paced by the display the window is +/// on. The wrapped dispatch source coalesces vsync ticks from the display's +/// io thread and invokes `callback(data)` on the main queue. +pub struct WindowFrameSource { + frame_requests: DispatchRetained, + registration: Option<(CGDirectDisplayID, SubscriberId)>, +} + +impl WindowFrameSource { + pub fn new(data: *mut c_void, callback: extern "C" fn(*mut c_void)) -> Self { + let frame_requests = unsafe { let frame_requests = DispatchSource::new( &raw const _dispatch_source_type_data_add as *mut _, 0, @@ -41,47 +244,39 @@ impl DisplayLink { ); frame_requests.set_context(data); frame_requests.set_event_handler_f(callback); + // Resume before this source can ever be dropped: destroying a + // suspended dispatch source is undefined behavior (#50875). frame_requests.resume(); - - let display_link = sys::DisplayLink::new( - display_id, - display_link_callback, - &*frame_requests as *const DispatchSource as *mut c_void, - )?; - - Ok(Self { - display_link: Some(display_link), - frame_requests, - }) + frame_requests + }; + Self { + frame_requests, + registration: None, } } - pub fn start(&mut self) -> Result<()> { - unsafe { - self.display_link.as_mut().unwrap().start()?; - } + pub fn start(&mut self, display_id: CGDirectDisplayID) -> Result<()> { + self.stop(); + let subscriber_id = subscribe(display_id, self.frame_requests.clone())?; + self.registration = Some((display_id, subscriber_id)); Ok(()) } - pub fn stop(&mut self) -> Result<()> { - unsafe { - self.display_link.as_mut().unwrap().stop()?; + pub fn stop(&mut self) { + if let Some((display_id, subscriber_id)) = self.registration.take() { + unsubscribe(display_id, subscriber_id); } - Ok(()) } } -impl Drop for DisplayLink { +impl Drop for WindowFrameSource { fn drop(&mut self) { - self.stop().log_err(); - // We see occasional segfaults on the CVDisplayLink thread. - // - // It seems possible that this happens because CVDisplayLinkRelease releases the CVDisplayLink - // on the main thread immediately, but the background thread that CVDisplayLink uses for timers - // is still accessing it. - // - // We might also want to upgrade to CADisplayLink, but that requires dropping old macOS support. - std::mem::forget(self.display_link.take()); + self.stop(); + // Unsubscribing makes this source unreachable from the output + // callback, so unlike before (ZED-7XR) it is safe to actually release + // it. Cancelling first guarantees the event handler never runs again; + // its context points at the window's native view, which may be + // deallocated after this. self.frame_requests.cancel(); } } diff --git a/crates/gpui_macos/src/gpui_macos.rs b/crates/gpui_macos/src/gpui_macos.rs index b9b55430b81fc7..9a9e4d7ae801a8 100644 --- a/crates/gpui_macos/src/gpui_macos.rs +++ b/crates/gpui_macos/src/gpui_macos.rs @@ -10,6 +10,7 @@ mod display_link; mod events; mod keyboard; mod pasteboard; +mod system_notifications; #[cfg(feature = "screen-capture")] mod screen_capture; diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 8266cade4d226b..b25067935e5f2b 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -31,7 +31,8 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowParams, + PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowKind, + WindowParams, popup::PopupNotSupportedError, }; use gpui_util::{ResultExt, new_std_command}; use itertools::Itertools; @@ -188,6 +189,7 @@ pub(crate) struct MacPlatformState { keyboard_mapper: Rc, /// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose. cursor_visible: Arc, + system_notifications: crate::system_notifications::SystemNotificationState, } impl MacPlatform { @@ -234,6 +236,7 @@ impl MacPlatform { menus: None, keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), + system_notifications: crate::system_notifications::SystemNotificationState::new(), })) } @@ -640,6 +643,12 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { + // Native popups are not implemented on macOS yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = options.kind { + return Err(PopupNotSupportedError.into()); + } + let (cursor_visible, foreground_executor, background_executor, renderer_context) = { let guard = self.0.lock(); ( @@ -961,6 +970,27 @@ impl Platform for MacPlatform { } } + fn show_system_notification(&self, notification: gpui::SystemNotification) { + let mut state = self.0.lock(); + let executor = state.foreground_executor.clone(); + state.system_notifications.show(&executor, notification); + } + + fn dismiss_system_notification(&self, tag: &str) { + let mut state = self.0.lock(); + let executor = state.foreground_executor.clone(); + state.system_notifications.dismiss(&executor, tag); + } + + fn on_system_notification_response( + &self, + callback: Box, + ) { + let mut state = self.0.lock(); + let executor = state.foreground_executor.clone(); + state.system_notifications.on_response(&executor, callback); + } + fn keyboard_layout(&self) -> Box { Box::new(MacKeyboardLayout::new()) } diff --git a/crates/gpui_macos/src/system_notifications.rs b/crates/gpui_macos/src/system_notifications.rs new file mode 100644 index 00000000000000..4bfd9522cacdef --- /dev/null +++ b/crates/gpui_macos/src/system_notifications.rs @@ -0,0 +1,309 @@ +//! System notifications through the `UNUserNotificationCenter` API. +//! +//! Everything here runs lazily: nothing touches the notification center (and +//! so nothing can trigger the authorization prompt or the framework's +//! not-in-a-bundle abort) until the application posts a notification or +//! registers a response callback. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; + +use block2::RcBlock; +use futures::StreamExt as _; +use futures::channel::mpsc; +use gpui::{ + ForegroundExecutor, SharedString, SystemNotification, SystemNotificationAction, + SystemNotificationResponse, Task, +}; +use objc2::rc::Retained; +use objc2::runtime::{Bool, ProtocolObject}; +use objc2::{AnyThread, DefinedClass, define_class, msg_send}; +use objc2_foundation::{NSArray, NSBundle, NSError, NSObject, NSObjectProtocol, NSSet, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNMutableNotificationContent, UNNotification, UNNotificationAction, + UNNotificationActionOptions, UNNotificationCategory, UNNotificationCategoryOptions, + UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, + UNNotificationRequest, UNNotificationResponse, UNUserNotificationCenter, + UNUserNotificationCenterDelegate, +}; + +type ResponseCallback = Rc>>>; + +pub(crate) struct SystemNotificationState { + initialized: bool, + center: Option, + callback: ResponseCallback, + _response_task: Option>, +} + +impl SystemNotificationState { + pub(crate) fn new() -> Self { + Self { + initialized: false, + center: None, + callback: Rc::new(RefCell::new(None)), + _response_task: None, + } + } + + pub(crate) fn show(&mut self, executor: &ForegroundExecutor, notification: SystemNotification) { + self.initialize(executor); + if let Some(center) = &self.center { + center.show(notification); + } + } + + pub(crate) fn dismiss(&mut self, executor: &ForegroundExecutor, tag: &str) { + self.initialize(executor); + if let Some(center) = &self.center { + center.dismiss(tag); + } + } + + pub(crate) fn on_response( + &mut self, + executor: &ForegroundExecutor, + callback: Box, + ) { + self.initialize(executor); + *self.callback.borrow_mut() = Some(callback); + } + + fn initialize(&mut self, executor: &ForegroundExecutor) { + if self.initialized { + return; + } + self.initialized = true; + + let (sender, mut receiver) = mpsc::unbounded(); + self.center = NotificationCenter::new(sender); + if self.center.is_none() { + return; + } + + // Responses arrive from the delegate on an arbitrary thread; this task + // hands them to the registered callback on the main thread. + let callback = self.callback.clone(); + self._response_task = Some(executor.spawn(async move { + while let Some(response) = receiver.next().await { + // Take the callback out for the call: it may re-enter the + // platform (e.g. to dismiss the notification it was told + // about) or replace itself. + let taken = callback.borrow_mut().take(); + if let Some(mut taken) = taken { + taken(response); + callback.borrow_mut().get_or_insert(taken); + } + } + })); + } +} + +struct NotificationCenter { + center: Retained, + /// The center's `delegate` property is weak; keeping the delegate retained + /// here keeps response handling alive for the app's lifetime. + _delegate: Retained, + /// The union of every action set registered so far. Categories are shared + /// by notifications with identical actions because macOS replaces the + /// entire registered set whenever a category is added. + categories: RefCell< + HashMap, (SharedString, Retained)>, + >, + authorization_requested: Cell, +} + +impl NotificationCenter { + fn new(sender: mpsc::UnboundedSender) -> Option { + // `UNUserNotificationCenter` raises `NSInternalInconsistencyException` + // ("bundleProxyForCurrentProcess is nil"), aborting the process, when + // the binary isn't part of an app bundle — which is how dev builds run + // via `cargo run`. A bundle identifier is only present when launched + // from a real `.app`, so use it as the guard. + if NSBundle::mainBundle().bundleIdentifier().is_none() { + log::info!("system notifications disabled: not running from an app bundle"); + return None; + } + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let delegate = NotificationResponseDelegate::new(sender); + center.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + + Some(Self { + center, + _delegate: delegate, + categories: RefCell::new(HashMap::new()), + authorization_requested: Cell::new(false), + }) + } + + fn request_authorization(&self) { + if self.authorization_requested.replace(true) { + return; + } + let completion = RcBlock::new(|granted: Bool, error: *mut NSError| { + // SAFETY: when non-null, `error` is a valid `NSError` for the + // duration of the callback. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!( + "system notification authorization failed: {}", + error.localizedDescription() + ); + } else if !granted.as_bool() { + log::info!("system notification authorization denied"); + } + }); + self.center + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &completion, + ); + } + + fn show(&self, notification: SystemNotification) { + self.request_authorization(); + + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(¬ification.title)); + content.setBody(&NSString::from_str(¬ification.body)); + if !notification.actions.is_empty() { + let category_identifier = self.register_category(¬ification.actions); + content.setCategoryIdentifier(&NSString::from_str(&category_identifier)); + } + + // A nil trigger delivers immediately. Reusing the tag as the request + // identifier makes a newer notification for the same tag replace the + // older one. + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(¬ification.tag), + &content, + None, + ); + let completion = RcBlock::new(|error: *mut NSError| { + // SAFETY: when non-null, `error` is a valid `NSError` for the + // duration of the callback. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!( + "failed to deliver system notification: {}", + error.localizedDescription() + ); + } + }); + self.center + .addNotificationRequest_withCompletionHandler(&request, Some(&completion)); + } + + fn register_category(&self, actions: &[SystemNotificationAction]) -> SharedString { + let mut categories = self.categories.borrow_mut(); + if let Some((identifier, _category)) = categories.get(actions) { + return identifier.clone(); + } + + let identifier = + SharedString::from(format!("gpui-system-notification-{}", categories.len())); + let platform_actions: Vec> = actions + .iter() + .map(|action| { + UNNotificationAction::actionWithIdentifier_title_options( + &NSString::from_str(&action.id), + &NSString::from_str(&action.label), + UNNotificationActionOptions::empty(), + ) + }) + .collect(); + let category = + UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options( + &NSString::from_str(&identifier), + &NSArray::from_retained_slice(&platform_actions), + &NSArray::new(), + UNNotificationCategoryOptions::empty(), + ); + + categories.insert(actions.to_vec(), (identifier.clone(), category)); + let all: Vec> = categories + .values() + .map(|(_identifier, category)| category.clone()) + .collect(); + self.center + .setNotificationCategories(&NSSet::from_retained_slice(&all)); + identifier + } + + fn dismiss(&self, tag: &str) { + let identifiers = NSArray::from_retained_slice(&[NSString::from_str(tag)]); + self.center + .removePendingNotificationRequestsWithIdentifiers(&identifiers); + self.center + .removeDeliveredNotificationsWithIdentifiers(&identifiers); + } +} + +struct DelegateIvars { + sender: mpsc::UnboundedSender, +} + +define_class!( + // SAFETY: `NSObject` has no subclassing requirements and + // `NotificationResponseDelegate` does not implement `Drop`. + #[unsafe(super(NSObject))] + #[ivars = DelegateIvars] + struct NotificationResponseDelegate; + + unsafe impl NSObjectProtocol for NotificationResponseDelegate {} + + unsafe impl UNUserNotificationCenterDelegate for NotificationResponseDelegate { + // Called when the user activates a delivered notification, possibly on + // a non-main thread; the channel hop hands the response to the + // foreground task holding the receiver. + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive_notification_response( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion_handler: &block2::DynBlock, + ) { + let tag = response.notification().request().identifier().to_string(); + let action = response.actionIdentifier(); + // The other well-known identifier, `UNNotificationDismissActionIdentifier`, + // is only delivered for categories opting into dismiss callbacks, + // which we never request. + let action_id = if &*action == unsafe { UNNotificationDefaultActionIdentifier } { + None + } else { + Some(SharedString::from(action.to_string())) + }; + self.ivars() + .sender + .unbounded_send(SystemNotificationResponse { + tag: SharedString::from(tag), + action_id, + }) + .ok(); + completion_handler.call(()); + } + + // Without this, macOS suppresses banners while the app is frontmost. + // Posting is the application's decision; whether the app is focused is + // its criterion to apply, not the platform's. + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present_notification( + &self, + _center: &UNUserNotificationCenter, + _notification: &UNNotification, + completion_handler: &block2::DynBlock, + ) { + completion_handler + .call((UNNotificationPresentationOptions::Banner + | UNNotificationPresentationOptions::List,)); + } + } +); + +impl NotificationResponseDelegate { + fn new(sender: mpsc::UnboundedSender) -> Retained { + let this = Self::alloc().set_ivars(DelegateIvars { sender }); + // SAFETY: `NSObject`'s `init` is its designated initializer. + unsafe { msg_send![super(this), init] } + } +} diff --git a/crates/gpui_macos/src/text_system.rs b/crates/gpui_macos/src/text_system.rs index 2808e535ad55e5..fbc48864ce5216 100644 --- a/crates/gpui_macos/src/text_system.rs +++ b/crates/gpui_macos/src/text_system.rs @@ -1,6 +1,6 @@ use anyhow::anyhow; use cocoa::appkit::CGFloat; -use collections::HashMap; +use collections::{HashMap, HashSet}; use core_foundation::{ array::{CFArray, CFArrayRef}, attributed_string::CFMutableAttributedString, @@ -282,6 +282,7 @@ impl MacTextSystemState { let name = gpui::font_name_with_fallbacks(name, ".AppleSystemUIFont"); let mut font_ids = SmallVec::new(); + let mut postscript_names_seen = HashSet::default(); let family = self .memory_source .select_family_by_name(name) @@ -340,15 +341,38 @@ impl MacTextSystemState { .is_some()) } { log::error!( - "Failed to read traits for font {:?}", - font.postscript_name().unwrap() + "Failed to read traits for font {:?} (PostScript name {:?})", + font.full_name(), + font.postscript_name(), ); continue; } + let Some(postscript_name) = font.postscript_name() else { + log::warn!( + "font {:?} in family {:?} has no PostScript name; skipping", + font.full_name(), + name, + ); + continue; + }; + // Dedup is scoped to this single `load_family` call (issue #55472). + // The same family can be reloaded later under a different `FontKey` + // (different features/fallbacks); a global check against + // `font_ids_by_postscript_name` would skip every already-registered + // font and leave the second call's `font_ids` empty. + if !postscript_names_seen.insert(postscript_name.clone()) { + log::warn!( + "skipping duplicate font {:?} with PostScript name {:?} \ + in family {:?}", + font.full_name(), + postscript_name, + name, + ); + continue; + } let font_id = FontId(self.fonts.len()); font_ids.push(font_id); - let postscript_name = font.postscript_name().unwrap(); self.font_ids_by_postscript_name .insert(postscript_name.clone(), font_id); self.postscript_names_by_font_id diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 938633d4749118..4314d07cc825f3 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1,6 +1,6 @@ use crate::{ - BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, TISCopyCurrentKeyboardInputSource, - TISGetInputSourceProperty, events::platform_input_from_native, + BoolExt, MacDisplay, NSRange, NSStringExt, TISCopyCurrentKeyboardInputSource, + TISGetInputSourceProperty, WindowFrameSource, events::platform_input_from_native, kTISPropertyInputSourceIsASCIICapable, kTISPropertyInputSourceType, kTISTypeKeyboardInputMode, ns_string, renderer, }; @@ -10,10 +10,11 @@ use block::ConcreteBlock; use cocoa::{ appkit::{ NSAppKitVersionNumber, NSAppKitVersionNumber12_0, NSApplication, NSBackingStoreBuffered, - NSColor, NSEvent, NSEventModifierFlags, NSFilenamesPboardType, NSPasteboard, NSScreen, - NSView, NSViewHeightSizable, NSViewWidthSizable, NSVisualEffectMaterial, - NSVisualEffectState, NSVisualEffectView, NSWindow, NSWindowCollectionBehavior, - NSWindowOcclusionState, NSWindowOrderingMode, NSWindowStyleMask, NSWindowTitleVisibility, + NSColor, NSEvent, NSEventModifierFlags, NSFilenamesPboardType, NSPasteboard, + NSRequestUserAttentionType, NSScreen, NSView, NSViewHeightSizable, NSViewWidthSizable, + NSVisualEffectMaterial, NSVisualEffectState, NSVisualEffectView, NSWindow, + NSWindowCollectionBehavior, NSWindowOcclusionState, NSWindowOrderingMode, + NSWindowStyleMask, NSWindowTitleVisibility, }, base::{id, nil}, foundation::{ @@ -288,6 +289,12 @@ unsafe fn build_classes() { accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, ); + decl.add_method( + sel!(_opaqueRectForWindowMoveWhenInTitlebar), + opaque_rect_for_window_move_when_in_titlebar + as extern "C" fn(&Object, Sel) -> NSRect, + ); + decl.add_method( sel!(characterIndexForPoint:), character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, @@ -490,7 +497,7 @@ struct MacWindowState { background_appearance: WindowBackgroundAppearance, cursor_style: CursorStyle, cursor_visible: Arc, - display_link: Option, + frame_source: Option, renderer: renderer::Renderer, request_frame_callback: Option>, event_callback: Option gpui::DispatchEventResult>>, @@ -512,6 +519,11 @@ struct MacWindowState { external_files_dragged: bool, // Whether the next left-mouse click is also the focusing click. first_mouse: bool, + // When true, the whole content view is reported as app-owned titlebar content via + // `_opaqueRectForWindowMoveWhenInTitlebar`, so AppKit does not drag the window from + // the titlebar or delay titlebar clicks (a delay first observed on macOS 27). Such + // windows draw their own titlebar and move the window via `start_window_move`. + app_owns_titlebar_drag: bool, fullscreen_restore_bounds: Bounds, move_tab_to_new_window_callback: Option>, merge_all_windows_callback: Option>, @@ -655,17 +667,21 @@ impl MacWindowState { return; } } - let display_id = unsafe { display_id_for_screen(self.native_window.screen()) }; - if let Some(mut display_link) = - DisplayLink::new(display_id, self.native_view.as_ptr() as *mut c_void, step).log_err() - { - display_link.start().log_err(); - self.display_link = Some(display_link); - } + let Some(display_id) = display_id_for_screen(unsafe { self.native_window.screen() }) else { + // AppKit can temporarily report no screen while displays are being reconfigured. + return; + }; + let data = self.native_view.as_ptr() as *mut c_void; + self.frame_source + .get_or_insert_with(|| WindowFrameSource::new(data, step)) + .start(display_id) + .log_err(); } fn stop_display_link(&mut self) { - self.display_link = None; + if let Some(frame_source) = self.frame_source.as_mut() { + frame_source.stop(); + } } fn is_maximized(&self) -> bool { @@ -743,6 +759,7 @@ impl MacWindow { titlebar, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, focus, @@ -792,7 +809,9 @@ impl MacWindow { WindowKind::Normal => { msg_send![WINDOW_CLASS, alloc] } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] } @@ -812,8 +831,10 @@ impl MacWindow { let count: u64 = cocoa::foundation::NSArray::count(screens); for i in 0..count { let screen = cocoa::foundation::NSArray::objectAtIndex(screens, i); + let Some(display_id) = display_id_for_screen(screen) else { + continue; + }; let frame = NSScreen::frame(screen); - let display_id = display_id_for_screen(screen); if display_id == display.0 { screen_frame = Some(frame); target_screen = screen; @@ -871,7 +892,7 @@ impl MacWindow { background_appearance: WindowBackgroundAppearance::Opaque, cursor_style: CursorStyle::Arrow, cursor_visible, - display_link: None, + frame_source: None, renderer: renderer::new_renderer( renderer_context, native_window as *mut _, @@ -902,6 +923,7 @@ impl MacWindow { do_command_handled: None, external_files_dragged: false, first_mouse: false, + app_owns_titlebar_drag, fullscreen_restore_bounds: Bounds::default(), move_tab_to_new_window_callback: None, merge_all_windows_callback: None, @@ -983,7 +1005,9 @@ impl MacWindow { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; } } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { // Use a tracking area to allow receiving MouseMoved events even when // the window or application aren't active, which is often the case // e.g. for notification windows. @@ -1148,7 +1172,7 @@ impl Drop for MacWindow { this.renderer.destroy(); let window = this.native_window; let sheet_parent = this.sheet_parent.take(); - this.display_link.take(); + this.frame_source.take(); unsafe { this.native_window.setDelegate_(nil); } @@ -1449,6 +1473,22 @@ impl PlatformWindow for MacWindow { .detach(); } + fn request_attention(&self) { + if self.is_active() { + return; + } + + let executor = self.0.lock().foreground_executor.clone(); + executor + .spawn(async move { + unsafe { + let app = NSApplication::sharedApplication(nil); + app.requestUserAttention_(NSRequestUserAttentionType::NSInformationalRequest); + } + }) + .detach(); + } + fn is_active(&self) -> bool { unsafe { self.0.lock().native_window.isKeyWindow() == YES } } @@ -2851,6 +2891,25 @@ extern "C" fn accepts_first_mouse(this: &Object, _: Sel, _: id) -> BOOL { YES } +// Reports which region of the view AppKit should treat as app-owned titlebar content +// (rather than a system-owned window-move region). When `app_owns_titlebar_drag` is +// true, we claim the entire view so AppKit neither drags the window from the titlebar +// nor waits to disambiguate double-clicks before delivering titlebar clicks (the macOS +// 27 delay); such windows implement dragging themselves via [`Window::start_window_move`]. +// Otherwise we return an empty rect so AppKit's native titlebar dragging keeps working. +// This is independent of `NSWindow.isMovable`, so the Window-menu tiling items stay +// enabled regardless. +extern "C" fn opaque_rect_for_window_move_when_in_titlebar(this: &Object, _: Sel) -> NSRect { + let zero_rect = NSRect::new(NSPoint::new(0., 0.), NSSize::new(0., 0.)); + let window_state = unsafe { get_window_state(this) }; + let app_owns_titlebar_drag = window_state.as_ref().lock().app_owns_titlebar_drag; + if app_owns_titlebar_drag { + unsafe { msg_send![this, bounds] } + } else { + zero_rect + } +} + extern "C" fn character_index_for_point(this: &Object, _: Sel, position: NSPoint) -> u64 { let position = screen_point_to_gpui_point(this, position); with_input_handler(this, |input_handler| { @@ -2995,13 +3054,17 @@ where } } -unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { +fn display_id_for_screen(screen: id) -> Option { + if screen.is_null() { + return None; + } + unsafe { let device_description = NSScreen::deviceDescription(screen); let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; - screen_number as CGDirectDisplayID + Some(screen_number as CGDirectDisplayID) } } @@ -3153,3 +3216,13 @@ extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_id_for_screen_returns_none_for_null_screen() { + assert_eq!(display_id_for_screen(nil), None); + } +} diff --git a/crates/gpui_macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs index 89d609ae65d604..51d2a8ab3f7214 100644 --- a/crates/gpui_macros/src/derive_into_element.rs +++ b/crates/gpui_macros/src/derive_into_element.rs @@ -11,11 +11,11 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream { impl #impl_generics gpui::IntoElement for #type_name #type_generics #where_clause { - type Element = gpui::Component; + type Element = gpui::ViewElement; #[track_caller] fn into_element(self) -> Self::Element { - gpui::Component::new(self) + gpui::ViewElement::new(self) } } }; diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index f3958bca568c94..50305af752c393 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -29,8 +29,8 @@ pub fn register_action(ident: TokenStream) -> TokenStream { register_action::register_action(ident) } -/// #[derive(IntoElement)] is used to create a Component out of anything that implements -/// the `RenderOnce` trait. +/// #[derive(IntoElement)] generates an `IntoElement` impl for any `RenderOnce` +/// type, wrapping it in a `ViewElement` so it can be used as a child. #[proc_macro_derive(IntoElement)] pub fn derive_into_element(input: TokenStream) -> TokenStream { derive_into_element::derive_into_element(input) diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index 5980fa5e855214..643310f3ac174a 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -31,10 +31,12 @@ wasm-bindgen-futures = "0.4" web-time.workspace = true console_error_panic_hook = "0.1.7" js-sys = "0.3" -raw-window-handle = "0.6" +raw-window-handle.workspace = true wasm_thread = { version = "0.3", features = ["es_modules"], optional = true } web-sys = { version = "0.3", features = [ "console", + "Clipboard", + "ClipboardEvent", "CompositionEvent", "CssStyleDeclaration", "DataTransfer", diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 46be646cb56f07..31a37d527f1d7d 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -64,6 +64,7 @@ impl WebWindowInner { self.register_dragleave(), self.register_key_down(), self.register_key_up(), + self.register_paste(), self.register_composition_start(), self.register_composition_update(), self.register_composition_end(), @@ -150,13 +151,21 @@ impl WebWindowInner { current_state.modifiers = modifiers; } - this.dispatch_input(PlatformInput::MouseDown(MouseDownEvent { + let result = this.dispatch_input(PlatformInput::MouseDown(MouseDownEvent { button, position, modifiers, click_count, first_mouse: false, })); + + // The browser fires `contextmenu` after this event; remember whether + // the app claimed the right-click (e.g. opened its own menu) so the + // contextmenu listener cancels the native menu only in that case. + if button == MouseButton::Right { + let claimed = result.is_some_and(|result| !result.propagate); + this.app_claimed_right_click.set(claimed); + } }) } @@ -270,9 +279,12 @@ impl WebWindowInner { } fn register_context_menu(self: &Rc) -> Closure { + let this = Rc::clone(self); self.listen("contextmenu", move |event: JsValue| { let event: web_sys::Event = event.unchecked_into(); - event.prevent_default(); + if this.app_claimed_right_click.replace(false) { + event.prevent_default(); + } }) } @@ -351,8 +363,6 @@ impl WebWindowInner { return; } - event.prevent_default(); - let is_held = event.repeat(); let key_char = compute_key_char(&event, &key, &modifiers); @@ -370,11 +380,13 @@ impl WebWindowInner { if let Some(result) = result { if !result.propagate { + event.prevent_default(); return; } } if this.is_composing.get() || event.is_composing() { + event.prevent_default(); return; } @@ -383,6 +395,11 @@ impl WebWindowInner { this.with_input_handler(|handler| { handler.replace_text_in_range(None, &text); }); + // The character went into the input handler; suppress browser + // side-effects for the same keystroke (space scrolling the + // page, quick-find, etc.). Everything not handled above falls + // through so browser shortcuts keep their defaults. + event.prevent_default(); } } }) @@ -413,8 +430,6 @@ impl WebWindowInner { return; } - event.prevent_default(); - let key_char = compute_key_char(&event, &key, &modifiers); let keystroke = Keystroke { @@ -423,7 +438,38 @@ impl WebWindowInner { key_char, }; - this.dispatch_input(PlatformInput::KeyUp(KeyUpEvent { keystroke })); + let result = this.dispatch_input(PlatformInput::KeyUp(KeyUpEvent { keystroke })); + if let Some(result) = result { + if !result.propagate { + event.prevent_default(); + } + } + }) + } + + /// Paste is delivered through the DOM `paste` event rather than + /// `Platform::read_from_clipboard`: the browser's asynchronous clipboard + /// read API cannot fit that synchronous signature, while `ClipboardEvent` + /// exposes `clipboardData` synchronously inside the event. It fires for + /// any browser-initiated paste (keyboard, menu bar, context menu). + fn register_paste(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("paste", move |event: JsValue| { + let event: web_sys::ClipboardEvent = event.unchecked_into(); + let Some(clipboard_data) = event.clipboard_data() else { + return; + }; + let Ok(text) = clipboard_data.get_data("text/plain") else { + return; + }; + if text.is_empty() { + return; + } + + event.prevent_default(); + this.with_input_handler(|handler| { + handler.replace_text_in_range(None, &text); + }); }) } diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index fecc39f368dc8c..f70520d629d59e 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -8,7 +8,7 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, - ThermalState, WindowAppearance, WindowParams, + ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, }; use gpui_wgpu::WgpuContext; use std::{ @@ -166,6 +166,12 @@ impl Platform for WebPlatform { handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { + // Native popups are not implemented on the web yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(PopupNotSupportedError.into()); + } + let context_ref = self.wgpu_context.borrow(); let context = context_ref.as_ref().ok_or_else(|| { anyhow::anyhow!("WebGPU context not initialized. Was Platform::run() called?") @@ -336,7 +342,15 @@ impl Platform for WebPlatform { None } - fn write_to_clipboard(&self, _item: ClipboardItem) {} + fn write_to_clipboard(&self, item: ClipboardItem) { + if let Some(text) = item.text() + && let Some(window) = web_sys::window() + { + // Fire-and-forget; called synchronously inside the user's input + // event, which satisfies the browser's user-activation requirement. + drop(window.navigator().clipboard().write_text(&text)); + } + } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { Task::ready(Err(anyhow::anyhow!( diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index b9399d32e63e0f..5f4223572b33be 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -55,6 +55,7 @@ pub(crate) struct WebWindowInner { pub(crate) last_physical_size: Cell<(u32, u32)>, pub(crate) notify_scale: Cell, pub(crate) is_composing: Cell, + pub(crate) app_claimed_right_click: Cell, mql_handle: RefCell>, pending_physical_size: Cell>, } @@ -182,6 +183,7 @@ impl WebWindow { last_physical_size: Cell::new((0, 0)), notify_scale: Cell::new(false), is_composing: Cell::new(false), + app_claimed_right_click: Cell::new(false), mql_handle: RefCell::new(None), pending_physical_size: Cell::new(None), }); diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 61a5746df35c6a..181b3588b4bbd5 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -26,7 +26,7 @@ itertools.workspace = true log.workspace = true parking_lot.workspace = true profiling.workspace = true -raw-window-handle = "0.6" +raw-window-handle.workspace = true smallvec.workspace = true swash = "0.2.6" unicode-segmentation.workspace = true diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index 7d19bb7c086355..1d45bf89890d68 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1213,7 +1213,7 @@ fn fs_underline(input: UnderlineVarying) -> @location(0) vec4 { } let underline = b_underlines[input.underline_id]; - if ((underline.wavy & 0xFFu) == 0u) + if (underline.wavy == 0u) { return blend_color(input.color, input.color.a); } @@ -1327,7 +1327,7 @@ fn fs_poly_sprite(input: PolySpriteVarying) -> @location(0) vec4 { let distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii); var color = sample; - if ((sprite.grayscale & 0xFFu) != 0u) { + if (sprite.grayscale != 0u) { let grayscale = dot(color.rgb, GRAYSCALE_FACTORS); color = vec4(vec3(grayscale), sample.a); } diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 371c5384b0e428..64449258e179bc 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -983,7 +983,9 @@ impl WgpuRenderer { self.surface_config.height = clamped_height.max(1); let surface_config = self.surface_config.clone(); - let resources = self.resources_mut(); + let Some(resources) = self.resources.as_mut() else { + return; + }; // Wait for any in-flight GPU work to complete before destroying textures if let Err(e) = resources.device.poll(wgpu::PollType::Wait { @@ -1056,7 +1058,9 @@ impl WgpuRenderer { let surface_config = self.surface_config.clone(); let path_sample_count = self.rendering_params.path_sample_count; let dual_source_blending = self.dual_source_blending; - let resources = self.resources_mut(); + let Some(resources) = self.resources.as_mut() else { + return; + }; resources .surface .configure(&resources.device, &surface_config); diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 24799c0258cedc..0715cbc78ae1bf 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -33,7 +33,7 @@ itertools.workspace = true log.workspace = true parking_lot.workspace = true rand.workspace = true -raw-window-handle = "0.6" +raw-window-handle.workspace = true smallvec.workspace = true uuid.workspace = true windows.workspace = true diff --git a/crates/gpui_windows/src/direct_write.rs b/crates/gpui_windows/src/direct_write.rs index ebf99c8951aa2f..08ae7a998f8a06 100644 --- a/crates/gpui_windows/src/direct_write.rs +++ b/crates/gpui_windows/src/direct_write.rs @@ -608,7 +608,7 @@ impl DirectWriteState { } let mut runs = Vec::new(); - let renderer_context = RendererContext { + let mut renderer_context = RendererContext { text_system: self, components, index_converter: StringIndexConverter::new(text), @@ -616,7 +616,7 @@ impl DirectWriteState { width: 0.0, }; text_layout.Draw( - Some((&raw const renderer_context).cast::()), + Some((&raw mut renderer_context).cast::().cast_const()), &components.text_renderer.0, 0.0, 0.0, @@ -881,6 +881,10 @@ impl DirectWriteState { params: &RenderGlyphParams, glyph_bounds: Bounds, ) -> Result> { + // INVARIANT: the code below drives the *shared* D3D11 immediate context + // (`Map`/`Unmap`/`Draw`/`CopyResource`), which `DirectXRenderer` and `DirectXAtlas` also + // touch. An immediate `ID3D11DeviceContext` is not thread-safe, so this must only run on + // the main UI thread (which it always is; text rasterization never leaves that thread). let bitmap_size = glyph_bounds.size; let subpixel_shift = params .subpixel_variant @@ -1174,6 +1178,10 @@ impl DirectWriteState { }; } + // Release the mapping now that the rows have been copied out; leaving `staging_texture` + // mapped would leak the mapping and keep the resource pinned for later reuse. + unsafe { device_context.Unmap(&staging_texture, 0) }; + // Convert from premultiplied to straight alpha for chunk in rasterized.chunks_exact_mut(4) { let b = chunk[0] as f32; @@ -1519,13 +1527,34 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl { let color_font = unsafe { font_face.IsColorFont().as_bool() }; - let glyph_ids = unsafe { std::slice::from_raw_parts(glyphrun.glyphIndices, glyph_count) }; - let glyph_advances = - unsafe { std::slice::from_raw_parts(glyphrun.glyphAdvances, glyph_count) }; - let glyph_offsets = - unsafe { std::slice::from_raw_parts(glyphrun.glyphOffsets, glyph_count) }; - let cluster_map = - unsafe { std::slice::from_raw_parts(desc.clusterMap, desc.stringLength as usize) }; + let glyph_ids = unsafe { + slice_from_nullable( + glyphrun.glyphIndices, + glyph_count, + "DirectWrite returned a null glyph indices array", + )? + }; + let glyph_advances = unsafe { + slice_from_nullable( + glyphrun.glyphAdvances, + glyph_count, + "DirectWrite returned a null glyph advances array", + )? + }; + let glyph_offsets = unsafe { + slice_from_nullable( + glyphrun.glyphOffsets, + glyph_count, + "DirectWrite returned a null glyph offsets array", + )? + }; + let cluster_map = unsafe { + slice_from_nullable( + desc.clusterMap, + desc.stringLength as usize, + "DirectWrite returned a null cluster map", + )? + }; let cluster_analyzer = ClusterAnalyzer::new(cluster_map, glyph_count); let mut utf16_idx = desc.textPosition as usize; @@ -1605,6 +1634,29 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl { } } +/// Interprets an optional DirectWrite array pointer as a slice, treating a +/// null pointer with a zero length as an empty slice. A null pointer with a +/// nonzero length fails with `null_error_message`. +/// +/// # Safety +/// +/// When `ptr` is non-null, the caller must guarantee that it points to a valid +/// array of at least `len` elements that outlives the returned slice. +unsafe fn slice_from_nullable<'a, T>( + ptr: *const T, + len: usize, + null_error_message: &str, +) -> windows::core::Result<&'a [T]> { + if ptr.is_null() { + if len != 0 { + return Err(Error::new(E_INVALIDARG, null_error_message)); + } + Ok(&[]) + } else { + Ok(unsafe { std::slice::from_raw_parts(ptr, len) }) + } +} + struct StringIndexConverter<'a> { text: &'a str, utf8_ix: usize, diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs index d5b1a67430951b..62386997f5efd4 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -282,6 +282,24 @@ impl DirectXAtlasTexture { bounds: Bounds, bytes: &[u8], ) { + // `UpdateSubresource` reads `row_pitch * height` bytes from `bytes` based on the + // `D3D11_BOX` below. If the caller hands us a slice shorter than that, the driver would + // over-read past the end of the source buffer (potentially by multiple megabytes), so bail + // out instead. This is a first-insert path rather than a per-frame one, so the check is + // effectively free. + let row_bytes = bounds.size.width.to_bytes(self.bytes_per_pixel as u8) as usize; + let expected = row_bytes * bounds.size.height.0.max(0) as usize; + if bytes.len() < expected { + log::error!( + "DirectXAtlasTexture::upload: source slice is {} bytes but the {}x{} region \ + requires {} bytes; skipping upload to avoid a driver over-read", + bytes.len(), + bounds.size.width.0, + bounds.size.height.0, + expected, + ); + return; + } unsafe { device_context.UpdateSubresource( &self.texture, diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 03666241e674d5..8a350682e239a7 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -16,7 +16,7 @@ use windows::{ Dxgi::{Common::*, *}, }, }, - core::Interface, + core::{HSTRING, Interface}, }; use crate::directx_renderer::shader_resources::{RawShaderBytes, ShaderModule, ShaderTarget}; @@ -63,6 +63,7 @@ pub(crate) struct DirectXRendererDevices { pub(crate) device: ID3D11Device, pub(crate) device_context: ID3D11DeviceContext, dxgi_device: Option, + annotation: Option, } struct DirectXResources { @@ -97,6 +98,21 @@ struct DirectXGlobalElements { sampler: Option, } +struct Annotation<'a>(&'a ID3DUserDefinedAnnotation); + +impl<'a> Annotation<'a> { + fn new(annotation: &'a ID3DUserDefinedAnnotation, label: HSTRING) -> Self { + unsafe { annotation.BeginEvent(&label) }; + Self(annotation) + } +} + +impl Drop for Annotation<'_> { + fn drop(&mut self) { + unsafe { self.0.EndEvent() }; + } +} + struct DirectComposition { comp_device: IDCompositionDevice, comp_target: IDCompositionTarget, @@ -119,6 +135,7 @@ impl DirectXRendererDevices { } else { Some(device.cast().context("Creating DXGI device")?) }; + let annotation = device_context.cast().ok(); Ok(Self { adapter: adapter.clone(), @@ -126,6 +143,7 @@ impl DirectXRendererDevices { device: device.clone(), device_context: device_context.clone(), dxgi_device, + annotation, }) } } @@ -318,7 +336,15 @@ impl DirectXRenderer { self.upload_scene_buffers(scene)?; + let annotation = self + .devices + .as_ref() + .and_then(|devices| devices.annotation.clone()) + .filter(|annotation| unsafe { annotation.GetStatus().as_bool() }); for batch in scene.batches() { + let _annotation = annotation + .as_ref() + .map(|annotation| Annotation::new(annotation, HSTRING::from(batch.label()))); match batch { PrimitiveBatch::Shadows(range) => self.draw_shadows(range.start, range.len()), PrimitiveBatch::Quads(range) => self.draw_quads(range.start, range.len()), @@ -339,18 +365,20 @@ impl DirectXRenderer { } PrimitiveBatch::Surfaces(range) => self.draw_surfaces(&scene.surfaces[range]), } - .context(format!( - "scene too large:\ - {} paths, {} shadows, {} quads, {} underlines, {} mono, {} subpixel, {} poly, {} surfaces", - scene.paths.len(), - scene.shadows.len(), - scene.quads.len(), - scene.underlines.len(), - scene.monochrome_sprites.len(), - scene.subpixel_sprites.len(), - scene.polychrome_sprites.len(), - scene.surfaces.len(), - ))?; + .with_context(|| { + format!( + "scene too large:\ + {} paths, {} shadows, {} quads, {} underlines, {} mono, {} subpixel, {} poly, {} surfaces", + scene.paths.len(), + scene.shadows.len(), + scene.quads.len(), + scene.underlines.len(), + scene.monochrome_sprites.len(), + scene.subpixel_sprites.len(), + scene.polychrome_sprites.len(), + scene.surfaces.len(), + ) + })?; } self.present() } diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index d1dd5685b07d7e..55f18b50c0716d 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -1,4 +1,6 @@ use std::{ + ffi::c_void, + ptr::NonNull, sync::atomic::{AtomicBool, Ordering}, thread::{ThreadId, current}, time::Duration, @@ -6,16 +8,16 @@ use std::{ use anyhow::Context; use gpui_util::ResultExt; -use windows::{ +use windows::Win32::{ + Foundation::{FILETIME, LPARAM, WPARAM}, + Media::{timeBeginPeriod, timeEndPeriod}, System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, - }, - Win32::{ - Foundation::{LPARAM, WPARAM}, - Media::{timeBeginPeriod, timeEndPeriod}, - System::Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, - UI::WindowsAndMessaging::PostMessageW, + CloseThreadpoolTimer, CreateThreadpoolTimer, GetCurrentThread, PTP_CALLBACK_INSTANCE, + PTP_TIMER, SetThreadPriority, SetThreadpoolTimer, THREAD_PRIORITY_TIME_CRITICAL, + TP_CALLBACK_ENVIRON_V3, TP_CALLBACK_PRIORITY, TP_CALLBACK_PRIORITY_HIGH, + TP_CALLBACK_PRIORITY_LOW, TP_CALLBACK_PRIORITY_NORMAL, TrySubmitThreadpoolCallback, }, + UI::WindowsAndMessaging::PostMessageW, }; use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; @@ -49,29 +51,41 @@ impl WindowsDispatcher { } } - fn dispatch_on_threadpool(&self, priority: WorkItemPriority, runnable: RunnableVariant) { - let handler = { - let mut task_wrapper = Some(runnable); - WorkItemHandler::new(move |_| { - let runnable = task_wrapper.take().unwrap(); - Self::execute_runnable(runnable); - Ok(()) - }) + fn dispatch_on_threadpool(&self, priority: TP_CALLBACK_PRIORITY, runnable: RunnableVariant) { + let environ = TP_CALLBACK_ENVIRON_V3 { + Version: 3, + CallbackPriority: priority, + Size: size_of::() as u32, + ..Default::default() }; - ThreadPool::RunWithPriorityAsync(&handler, priority).log_err(); + // If the thread pool never runs our callback, the matching `from_raw` is never called, which leaks the runnable. + // Dropping the scheduled runnable would cancel its task and make the next poll of any awaiter panic. Since we expect + // the scenario to usually happen during shutdown, this leak is acceptable. + let context = runnable.into_raw().as_ptr() as *mut c_void; + + unsafe { + TrySubmitThreadpoolCallback(Some(run_work_callback), Some(context), Some(&environ)) + .log_err(); + } } fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) { - let handler = { - let mut task_wrapper = Some(runnable); - TimerElapsedHandler::new(move |_| { - let runnable = task_wrapper.take().unwrap(); - Self::execute_runnable(runnable); - Ok(()) - }) - }; - ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err(); + let context = runnable.into_raw().as_ptr() as *mut c_void; + + unsafe { + if let Ok(timer) = CreateThreadpoolTimer(Some(run_timer_callback), Some(context), None) + { + // Negative FILETIME expresses a relative delay in 100ns ticks + let ticks = (duration.as_nanos() / 100).min(i64::MAX as u128) as i64; + let due = (-ticks) as u64; + let due_time = FILETIME { + dwLowDateTime: due as u32, + dwHighDateTime: (due >> 32) as u32, + }; + SetThreadpoolTimer(timer, Some(&due_time), 0, None); + } + } } #[inline(always)] @@ -94,9 +108,9 @@ impl PlatformDispatcher for WindowsDispatcher { Priority::RealtimeAudio => { panic!("RealtimeAudio priority should use spawn_realtime, not dispatch") } - Priority::High => WorkItemPriority::High, - Priority::Medium => WorkItemPriority::Normal, - Priority::Low => WorkItemPriority::Low, + Priority::High => TP_CALLBACK_PRIORITY_HIGH, + Priority::Medium => TP_CALLBACK_PRIORITY_NORMAL, + Priority::Low => TP_CALLBACK_PRIORITY_LOW, }; self.dispatch_on_threadpool(priority, runnable); } @@ -157,3 +171,21 @@ impl PlatformDispatcher for WindowsDispatcher { })) } } + +unsafe extern "system" fn run_work_callback( + _instance: PTP_CALLBACK_INSTANCE, + context: *mut c_void, +) { + let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) }; + WindowsDispatcher::execute_runnable(runnable); +} + +unsafe extern "system" fn run_timer_callback( + _instance: PTP_CALLBACK_INSTANCE, + context: *mut c_void, + timer: PTP_TIMER, +) { + let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) }; + WindowsDispatcher::execute_runnable(runnable); + unsafe { CloseThreadpoolTimer(timer) }; +} diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 619765a2fd6abe..b293763bd9ede4 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -903,8 +903,12 @@ impl WindowsWindowInner { WindowControlArea::Drag if self.is_movable => Some(HTCAPTION as _), WindowControlArea::Drag => None, WindowControlArea::Close => Some(HTCLOSE as _), - WindowControlArea::Max => Some(HTMAXBUTTON as _), - WindowControlArea::Min => Some(HTMINBUTTON as _), + WindowControlArea::Max if self.is_resizable => Some(HTMAXBUTTON as _), + WindowControlArea::Max if self.is_movable => Some(HTCAPTION as _), + WindowControlArea::Max => Some(HTNOWHERE as _), + WindowControlArea::Min if self.is_minimizable => Some(HTMINBUTTON as _), + WindowControlArea::Min if self.is_movable => Some(HTCAPTION as _), + WindowControlArea::Min => Some(HTNOWHERE as _), }) } else { None @@ -926,7 +930,11 @@ impl WindowsWindowInner { }; unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; - if !self.state.is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y { + if self.is_resizable + && !self.state.is_maximized() + && 0 <= cursor_point.y + && cursor_point.y <= frame_y + { // x-axis actually goes from -frame_x to 0 return Some(if cursor_point.x <= 0 { HTTOPLEFT @@ -1052,11 +1060,12 @@ impl WindowsWindowInner { && let Some(last_pressed) = last_pressed { let handled = match (wparam.0 as u32, last_pressed) { - (HTMINBUTTON, HTMINBUTTON) => { + (HTMINBUTTON, HTMINBUTTON) if self.is_minimizable => { unsafe { ShowWindowAsync(handle, SW_MINIMIZE).ok().log_err() }; true } - (HTMAXBUTTON, HTMAXBUTTON) => { + (HTMINBUTTON, HTMINBUTTON) => true, + (HTMAXBUTTON, HTMAXBUTTON) if self.is_resizable => { if self.state.is_maximized() { unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() }; } else { @@ -1064,6 +1073,7 @@ impl WindowsWindowInner { } true } + (HTMAXBUTTON, HTMAXBUTTON) => true, (HTCLOSE, HTCLOSE) => { unsafe { PostMessageW(Some(handle), WM_CLOSE, WPARAM::default(), LPARAM::default()) diff --git a/crates/gpui_windows/src/gpui_windows.rs b/crates/gpui_windows/src/gpui_windows.rs index 0af5411d20e4fb..692d0dbbfd1f4b 100644 --- a/crates/gpui_windows/src/gpui_windows.rs +++ b/crates/gpui_windows/src/gpui_windows.rs @@ -12,6 +12,7 @@ mod display; mod events; mod keyboard; mod platform; +mod system_notifications; mod system_settings; mod util; mod vsync; @@ -29,6 +30,7 @@ pub(crate) use display::*; pub(crate) use events::*; pub(crate) use keyboard::*; pub(crate) use platform::*; +pub(crate) use system_notifications::*; pub(crate) use system_settings::*; pub(crate) use util::*; pub(crate) use vsync::*; diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 871da413e3bbee..f8939e9e4c393f 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -47,6 +47,9 @@ pub struct WindowsPlatform { handle: HWND, suspend_resume_notification: RefCell>, disable_direct_composition: bool, + has_package_identity: bool, + app_identity: RefCell>, + system_notifications: RefCell, } struct WindowsPlatformInner { @@ -197,8 +200,11 @@ impl WindowsPlatform { direct_write_text_system, suspend_resume_notification: RefCell::new(None), disable_direct_composition, + has_package_identity: has_package_identity(), drop_target_helper, invalidate_devices: Arc::new(AtomicBool::new(false)), + app_identity: RefCell::new(None), + system_notifications: RefCell::new(SystemNotificationState::new()), }) } @@ -417,17 +423,6 @@ impl Platform for WindowsPlatform { self.inner .with_callback(|callbacks| &callbacks.quit, |callback| callback()); - - // Bypass the CRT exit logic, which runs atexit handlers before calling ExitProcess. - // aws-lc registers an atexit handler that intentionally acquires a lock without releasing it. - // aws-lc also has thread_local objects which acquire this lock in their destructor. - // Destructors for thread_locals run under the loader lock, so there is a race condition - // where, if a thread exits after atexit handlers have run, the TLS destructors will block - // indefinitely on this lock while holding the loader lock. Since ExitProcess also requires - // the loader lock, process teardown will deadlock. - unsafe { - windows::Win32::System::Threading::ExitProcess(0); - } } fn quit(&self) { @@ -646,6 +641,51 @@ impl Platform for WindowsPlatform { } } + fn set_app_identity(&self, identifier: &str, name: &str) { + // If the process has package identity, it's automatally granted an AUMID by the system. + if self.has_package_identity { + return; + } + + let identifier_utf16 = windows::core::HSTRING::from(identifier); + // SAFETY: `identifier_utf16` outlives the call and is null-terminated. + if let Err(error) = unsafe { + windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID( + windows::core::PCWSTR(identifier_utf16.as_ptr()), + ) + } { + log::warn!("failed to set the process AppUserModelID: {error}"); + } + *self.app_identity.borrow_mut() = Some((identifier.to_string(), name.to_string())); + } + + fn show_system_notification(&self, notification: gpui::SystemNotification) { + let app_identity = self.app_identity.borrow().clone(); + self.system_notifications + .borrow_mut() + .show( + self.has_package_identity, + app_identity + .as_ref() + .map(|(identifier, name)| (identifier.as_str(), name.as_str())), + notification, + ) + .log_err(); + } + + fn dismiss_system_notification(&self, tag: &str) { + self.system_notifications.borrow_mut().dismiss(tag); + } + + fn on_system_notification_response( + &self, + callback: Box, + ) { + self.system_notifications + .borrow_mut() + .on_response(&self.foreground_executor, callback); + } + fn set_menus(&self, menus: Vec

, _keymap: &Keymap) { *self.inner.state.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect(); } @@ -741,6 +781,15 @@ impl Platform for WindowsPlatform { } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { + // CredWriteW rejects larger blobs with the opaque RPC error + // 0x800706F7 "The stub received bad data", so fail with a clear + // message instead. + if password.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize { + return Task::ready(Err(anyhow!( + "credential for {url} is {} bytes, which exceeds the Windows Credential Manager limit of {CRED_MAX_CREDENTIAL_BLOB_SIZE} bytes", + password.len() + ))); + } let password = password.to_vec(); let mut username = username.encode_utf16().chain(Some(0)).collect_vec(); let mut target_name = windows_credentials_target_name(url) @@ -1095,6 +1144,24 @@ struct PlatformWindowCreateContext { dispatcher: Option>, } +fn has_package_identity() -> bool { + let mut package_full_name_length = 0; + let result = unsafe { + windows::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName( + &mut package_full_name_length, + None, + ) + }; + if result == ERROR_INSUFFICIENT_BUFFER { + true + } else if result == APPMODEL_ERROR_NO_PACKAGE { + false + } else { + log::warn!("failed to determine whether the process has package identity: {result:?}"); + false + } +} + fn open_target(target: impl AsRef) -> Result<()> { let target = target.as_ref(); let ret = unsafe { diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index 763c026e77b000..4b27f65e966528 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -1271,7 +1271,7 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe float distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii); float4 color = sample; - if ((sprite.grayscale & 0xFFu) != 0u) { + if (sprite.grayscale != 0u) { float3 grayscale = dot(color.rgb, GRAYSCALE_FACTORS); color = float4(grayscale, sample.a); } diff --git a/crates/gpui_windows/src/system_notifications.rs b/crates/gpui_windows/src/system_notifications.rs new file mode 100644 index 00000000000000..b0075f0e39d781 --- /dev/null +++ b/crates/gpui_windows/src/system_notifications.rs @@ -0,0 +1,198 @@ +//! System notifications as Windows toast notifications. + +use std::cell::RefCell; +use std::collections::{HashMap, hash_map::DefaultHasher}; +use std::hash::{Hash as _, Hasher as _}; +use std::rc::Rc; + +use futures::StreamExt as _; +use futures::channel::mpsc; +use gpui::{ + ForegroundExecutor, SharedString, SystemNotification, SystemNotificationResponse, Task, +}; +use windows::Data::Xml::Dom::XmlDocument; +use windows::Foundation::TypedEventHandler; +use windows::UI::Notifications::{ + ToastActivatedEventArgs, ToastNotification, ToastNotificationManager, ToastNotifier, +}; +use windows::core::{IInspectable, Interface as _, h}; + +type ResponseCallback = Rc>>>; + +pub(crate) struct SystemNotificationState { + notifier: Option, + active_toasts: HashMap, + response_sender: mpsc::UnboundedSender, + response_receiver: Option>, + callback: ResponseCallback, + _response_task: Option>, +} + +impl SystemNotificationState { + pub(crate) fn new() -> Self { + let (response_sender, response_receiver) = mpsc::unbounded(); + Self { + notifier: None, + active_toasts: HashMap::new(), + response_sender, + response_receiver: Some(response_receiver), + callback: Rc::new(RefCell::new(None)), + _response_task: None, + } + } + + pub(crate) fn show( + &mut self, + has_package_identity: bool, + app_identity: Option<(&str, &str)>, + notification: SystemNotification, + ) -> windows::core::Result<()> { + let Some(notifier) = self.notifier(has_package_identity, app_identity)? else { + return Ok(()); + }; + + let document = toast_document(¬ification)?; + let toast = ToastNotification::CreateToastNotification(&document)?; + // Windows caps toast tags at 64 characters (post-Creators Update), + // so hash the arbitrary GPUI tag down to a fixed-width value. + let tag = { + let mut hasher = DefaultHasher::new(); + notification.tag.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }; + toast.SetTag(&tag.into())?; + + let sender = self.response_sender.clone(); + let response_tag = notification.tag.clone(); + toast.Activated(&TypedEventHandler::::new( + move |_sender, arguments| { + let action_id = arguments + .as_ref() + .and_then(|arguments| arguments.cast::().ok()) + .and_then(|arguments| arguments.Arguments().ok()) + .filter(|arguments| !arguments.is_empty()) + .map(|arguments| SharedString::from(arguments.to_string())); + sender + .unbounded_send(SystemNotificationResponse { + tag: response_tag.clone(), + action_id, + }) + .ok(); + Ok(()) + }, + ))?; + + if let Some(previous) = self.active_toasts.remove(¬ification.tag) { + notifier.Hide(&previous)?; + } + notifier.Show(&toast)?; + self.active_toasts.insert(notification.tag, toast); + Ok(()) + } + + pub(crate) fn dismiss(&mut self, tag: &str) { + let Some(toast) = self.active_toasts.remove(tag) else { + return; + }; + let Some(notifier) = &self.notifier else { + return; + }; + if let Err(error) = notifier.Hide(&toast) { + log::warn!("failed to dismiss system notification: {error}"); + } + } + + pub(crate) fn on_response( + &mut self, + executor: &ForegroundExecutor, + callback: Box, + ) { + *self.callback.borrow_mut() = Some(callback); + + if let Some(mut receiver) = self.response_receiver.take() { + let callback = self.callback.clone(); + self._response_task = Some(executor.spawn(async move { + while let Some(response) = receiver.next().await { + // Take the callback out for the call: it may re-enter the + // platform (e.g. to dismiss the notification it was told + // about) or replace itself. + let taken = callback.borrow_mut().take(); + if let Some(mut taken) = taken { + taken(response); + callback.borrow_mut().get_or_insert(taken); + } + } + })); + } + } + + fn notifier( + &mut self, + has_package_identity: bool, + app_identity: Option<(&str, &str)>, + ) -> windows::core::Result> { + if let Some(notifier) = &self.notifier { + return Ok(Some(notifier.clone())); + } + + let notifier = if has_package_identity { + ToastNotificationManager::CreateToastNotifier()? + } else { + let Some((app_identifier, app_name)) = app_identity else { + log::warn!( + "cannot show a system notification without an app identity; \ + call `App::set_app_identity` during startup" + ); + return Ok(None); + }; + register_app_user_model_id(app_identifier, app_name); + ToastNotificationManager::CreateToastNotifierWithId(&app_identifier.into())? + }; + + self.notifier = Some(notifier.clone()); + Ok(Some(notifier)) + } +} + +fn toast_document(notification: &SystemNotification) -> windows::core::Result { + let document = XmlDocument::new()?; + let toast = document.CreateElement(h!("toast"))?; + document.AppendChild(&toast)?; + + let visual = document.CreateElement(h!("visual"))?; + toast.AppendChild(&visual)?; + let binding = document.CreateElement(h!("binding"))?; + binding.SetAttribute(h!("template"), h!("ToastGeneric"))?; + visual.AppendChild(&binding)?; + for text in [¬ification.title, ¬ification.body] { + let element = document.CreateElement(h!("text"))?; + element.SetInnerText(&text.as_ref().into())?; + binding.AppendChild(&element)?; + } + + if !notification.actions.is_empty() { + let actions = document.CreateElement(h!("actions"))?; + toast.AppendChild(&actions)?; + for action in ¬ification.actions { + let action_element = document.CreateElement(h!("action"))?; + action_element.SetAttribute(h!("content"), &action.label.as_ref().into())?; + action_element.SetAttribute(h!("arguments"), &action.id.as_ref().into())?; + actions.AppendChild(&action_element)?; + } + } + + let audio = document.CreateElement(h!("audio"))?; + audio.SetAttribute(h!("silent"), h!("true"))?; + toast.AppendChild(&audio)?; + Ok(document) +} + +/// Registers the app's AUMID so toasts display correctly for an unpackaged app without a Start Menu shortcut. +fn register_app_user_model_id(app_identifier: &str, app_name: &str) { + let result = windows_registry::CURRENT_USER + .create(format!(r"Software\Classes\AppUserModelId\{app_identifier}")) + .and_then(|key| key.set_string("DisplayName", app_name)); + if let Err(error) = result { + log::warn!("failed to register AppUserModelID; notifications may not display: {error}"); + } +} diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index c9352a60035ace..49a935bafe42ed 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -94,6 +94,8 @@ pub(crate) struct WindowsWindowInner { pub(crate) handle: AnyWindowHandle, pub(crate) hide_title_bar: bool, pub(crate) is_movable: bool, + pub(crate) is_resizable: bool, + pub(crate) is_minimizable: bool, pub(crate) executor: ForegroundExecutor, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, @@ -262,6 +264,8 @@ impl WindowsWindowInner { handle: context.handle, hide_title_bar: context.hide_title_bar, is_movable: context.is_movable, + is_resizable: context.is_resizable, + is_minimizable: context.is_minimizable, executor: context.executor.clone(), validation_number: context.validation_number, main_receiver: context.main_receiver.clone(), @@ -384,6 +388,8 @@ struct WindowCreateContext { hide_title_bar: bool, display: WindowsDisplay, is_movable: bool, + is_resizable: bool, + is_minimizable: bool, min_size: Option>, executor: ForegroundExecutor, current_cursor: Option, @@ -405,6 +411,12 @@ impl WindowsWindow { params: WindowParams, creation_info: WindowCreationInfo, ) -> Result { + // Native popups are not implemented on Windows yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(popup::PopupNotSupportedError.into()); + } + let WindowCreationInfo { icon, executor, @@ -487,6 +499,8 @@ impl WindowsWindow { hide_title_bar, display, is_movable: params.is_movable, + is_resizable: params.is_resizable, + is_minimizable: params.is_minimizable, min_size: params.window_min_size, executor, current_cursor, @@ -806,6 +820,27 @@ impl PlatformWindow for WindowsWindow { .detach(); } + fn request_attention(&self) { + if self.is_active() { + return; + } + + let hwnd = self.0.hwnd; + self.0 + .executor + .spawn(async move { + let info = FLASHWINFO { + cbSize: std::mem::size_of::() as u32, + hwnd, + dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG, + uCount: 0, + dwTimeout: 0, + }; + unsafe { FlashWindowEx(&info).ok().log_err() }; + }) + .detach(); + } + fn is_active(&self) -> bool { self.0.hwnd == unsafe { GetActiveWindow() } } @@ -1531,8 +1566,12 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32 .log_err() { let func_name = PCSTR::from_raw(c"SetWindowCompositionAttribute".as_ptr() as *const u8); + let Some(raw_set_window_composition_attribute) = GetProcAddress(user32, func_name) + else { + return; + }; let set_window_composition_attribute: SetWindowCompositionAttributeType = - std::mem::transmute(GetProcAddress(user32, func_name)); + std::mem::transmute(raw_set_window_composition_attribute); let mut color = color.unwrap_or_default(); let is_acrylic = state == 4; if is_acrylic && color.3 == 0 { diff --git a/crates/grammars/Cargo.toml b/crates/grammars/Cargo.toml index 13b3bf5c94bb45..9d37f6cf265aa6 100644 --- a/crates/grammars/Cargo.toml +++ b/crates/grammars/Cargo.toml @@ -15,7 +15,6 @@ 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 } @@ -57,4 +56,4 @@ load-grammars = [ "tree-sitter-typescript", "tree-sitter-yaml", ] -test-support = ["load-grammars"] +test-support = ["load-grammars", "tree-sitter/wasm"] diff --git a/crates/grammars/src/bash/config.toml b/crates/grammars/src/bash/config.toml index adc2063341af73..d7cabce3c2a86f 100644 --- a/crates/grammars/src/bash/config.toml +++ b/crates/grammars/src/bash/config.toml @@ -1,7 +1,7 @@ name = "Shell Script" code_fence_block_name = "bash" grammar = "bash" -path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "envrc", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD"] +path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "envrc", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD", "ebuild"] modeline_aliases = ["sh", "shell", "shell-script", "zsh"] line_comments = ["# "] first_line_pattern = '^#!.*\b(?:ash|bash|bats|dash|sh|zsh)\b' diff --git a/crates/grammars/src/c/indents.scm b/crates/grammars/src/c/indents.scm index 41f3c4fd3d4a20..d119e5eedcee9b 100644 --- a/crates/grammars/src/c/indents.scm +++ b/crates/grammars/src/c/indents.scm @@ -17,6 +17,15 @@ "{" "}" @end) @indent +(compound_statement + (case_statement + ":" @start) + "}" @end) @indent + +(compound_statement + (case_statement) + (case_statement) @outdent) + (_ "(" ")" @end) @indent diff --git a/crates/grammars/src/cpp/indents.scm b/crates/grammars/src/cpp/indents.scm index d8c71736e9dd54..d8b677348b4f14 100644 --- a/crates/grammars/src/cpp/indents.scm +++ b/crates/grammars/src/cpp/indents.scm @@ -25,6 +25,15 @@ (access_specifier) (access_specifier) @outdent) +(compound_statement + (case_statement + ":" @start) + "}" @end) @indent + +(compound_statement + (case_statement) + (case_statement) @outdent) + (_ "(" ")" @end) @indent diff --git a/crates/grammars/src/go/outline.scm b/crates/grammars/src/go/outline.scm index da42904fab9426..29b9bd7554ac84 100644 --- a/crates/grammars/src/go/outline.scm +++ b/crates/grammars/src/go/outline.scm @@ -23,7 +23,7 @@ receiver: (parameter_list "(" @context (parameter_declaration - name: (_) @context + name: (_)? @context type: (_) @context) ")" @context) name: (field_identifier) @name diff --git a/crates/grammars/src/grammars.rs b/crates/grammars/src/grammars.rs index 00d6e6281c45b1..e3c70c9dc39d17 100644 --- a/crates/grammars/src/grammars.rs +++ b/crates/grammars/src/grammars.rs @@ -1,7 +1,8 @@ +use std::borrow::Cow; + use anyhow::Context as _; use language_core::{LanguageConfig, LanguageQueries, QUERY_FILENAME_PREFIXES}; use rust_embed::RustEmbed; -use util::asset_str; #[derive(RustEmbed)] #[folder = "src/"] @@ -95,7 +96,10 @@ pub fn load_queries(name: &str) -> LanguageQueries { } for (prefix, query) in QUERY_FILENAME_PREFIXES { if remainder.starts_with(prefix) { - let contents = asset_str::(path.as_ref()); + let contents = match GrammarDir::get(path.as_ref()).unwrap().data { + Cow::Borrowed(bytes) => Cow::Borrowed(std::str::from_utf8(bytes).unwrap()), + Cow::Owned(bytes) => Cow::Owned(String::from_utf8(bytes).unwrap()), + }; match query(&mut result) { None => *query(&mut result) = Some(contents), Some(existing) => existing.to_mut().push_str(contents.as_ref()), diff --git a/crates/grammars/src/javascript/highlights.scm b/crates/grammars/src/javascript/highlights.scm index 852564909878b9..336fcdd36a5600 100644 --- a/crates/grammars/src/javascript/highlights.scm +++ b/crates/grammars/src/javascript/highlights.scm @@ -98,6 +98,26 @@ (shorthand_property_identifier_pattern) ]) @variable.parameter)) +(required_parameter + (_ + (object_assignment_pattern + left: (shorthand_property_identifier_pattern) @variable.parameter))) + +(required_parameter + (_ + (assignment_pattern + left: (identifier) @variable.parameter))) + +(optional_parameter + (_ + (object_assignment_pattern + left: (shorthand_property_identifier_pattern) @variable.parameter))) + +(optional_parameter + (_ + (assignment_pattern + left: (identifier) @variable.parameter))) + (optional_parameter (identifier) @variable.parameter) diff --git a/crates/grammars/src/javascript/injections.scm b/crates/grammars/src/javascript/injections.scm index 8ccfc5028dea45..9d07511f952ed6 100644 --- a/crates/grammars/src/javascript/injections.scm +++ b/crates/grammars/src/javascript/injections.scm @@ -142,3 +142,25 @@ ]) (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") (#set! injection.language "css")) + +; '/* glsl */' or '/*glsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*glsl\\s*\\*\\/") + (#set! injection.language "glsl")) + +; '/* wgsl */' or '/*wgsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*wgsl\\s*\\*\\/") + (#set! injection.language "WGSL/WESL")) diff --git a/crates/grammars/src/javascript/outline.scm b/crates/grammars/src/javascript/outline.scm index ce6d9e6bd9469c..c87c53cbc28db1 100644 --- a/crates/grammars/src/javascript/outline.scm +++ b/crates/grammars/src/javascript/outline.scm @@ -169,6 +169,7 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -188,7 +189,10 @@ (identifier) @name ]))) @item -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -199,7 +203,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#eq? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/javascript/runnables.scm b/crates/grammars/src/javascript/runnables.scm index b410fb4d8cadd8..8b0b160c862179 100644 --- a/crates/grammars/src/javascript/runnables.scm +++ b/crates/grammars/src/javascript/runnables.scm @@ -1,5 +1,6 @@ ; Add support for (node:test, bun:test and Jest) runnable ; Function expression that has `it`, `test` or `describe` as the function name +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -20,7 +21,10 @@ ])) @_js-test (#set! tag js-test)) -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -31,7 +35,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#eq? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/json/config.toml b/crates/grammars/src/json/config.toml index 4b7f84d6f519e4..f3e228b55b7c93 100644 --- a/crates/grammars/src/json/config.toml +++ b/crates/grammars/src/json/config.toml @@ -1,6 +1,6 @@ name = "JSON" grammar = "json" -path_suffixes = ["json", "flake.lock", "geojson", "topojson", "prettierrc", "json.dist"] +path_suffixes = ["json", "flake.lock", "geojson", "topojson", "prettierrc", "json.dist", "deno.lock"] line_comments = ["// "] autoclose_before = ",]}" brackets = [ diff --git a/crates/grammars/src/markdown-inline/injections.scm b/crates/grammars/src/markdown-inline/injections.scm index 074b08fd87432f..3b82d3697c6459 100644 --- a/crates/grammars/src/markdown-inline/injections.scm +++ b/crates/grammars/src/markdown-inline/injections.scm @@ -1,2 +1,6 @@ +((html_tag) @injection.content + (#set! injection.language "html") + (#set! injection.combined)) + ((latex_block) @injection.content (#set! injection.language "latex")) diff --git a/crates/grammars/src/tsx/highlights.scm b/crates/grammars/src/tsx/highlights.scm index e8cb7a11065584..3dd9038782d814 100644 --- a/crates/grammars/src/tsx/highlights.scm +++ b/crates/grammars/src/tsx/highlights.scm @@ -98,6 +98,26 @@ (shorthand_property_identifier_pattern) ]) @variable.parameter)) +(required_parameter + (_ + (object_assignment_pattern + left: (shorthand_property_identifier_pattern) @variable.parameter))) + +(required_parameter + (_ + (assignment_pattern + left: (identifier) @variable.parameter))) + +(optional_parameter + (_ + (object_assignment_pattern + left: (shorthand_property_identifier_pattern) @variable.parameter))) + +(optional_parameter + (_ + (assignment_pattern + left: (identifier) @variable.parameter))) + (optional_parameter (identifier) @variable.parameter) diff --git a/crates/grammars/src/tsx/indents.scm b/crates/grammars/src/tsx/indents.scm index 1e72160bca2f5f..ffc35bfec968f6 100644 --- a/crates/grammars/src/tsx/indents.scm +++ b/crates/grammars/src/tsx/indents.scm @@ -5,10 +5,26 @@ (lexical_declaration) (variable_declaration) (assignment_expression) - (if_statement) - (for_statement) ] @indent +; Indent a braceless body (`if (x)\n y()`). When the body is a `{}` block the +; `@end` stops the range before the brace so the block rule handles it instead, +; which keeps Allman-style braces unindented. +(if_statement + consequence: (statement_block)? @end) @indent + +(else_clause + (statement_block)? @end) @indent + +(for_statement + body: (statement_block)? @end) @indent + +(for_in_statement + body: (statement_block)? @end) @indent + +(while_statement + body: (statement_block)? @end) @indent + (_ "[" "]" @end) @indent diff --git a/crates/grammars/src/tsx/outline.scm b/crates/grammars/src/tsx/outline.scm index 2c08c30ad587ff..19099b0076a87e 100644 --- a/crates/grammars/src/tsx/outline.scm +++ b/crates/grammars/src/tsx/outline.scm @@ -175,6 +175,7 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -194,7 +195,10 @@ (identifier) @name ]))) @item -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -205,7 +209,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/tsx/runnables.scm b/crates/grammars/src/tsx/runnables.scm index db1f69a2c22e5a..8b0b160c862179 100644 --- a/crates/grammars/src/tsx/runnables.scm +++ b/crates/grammars/src/tsx/runnables.scm @@ -1,5 +1,6 @@ ; Add support for (node:test, bun:test and Jest) runnable ; Function expression that has `it`, `test` or `describe` as the function name +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -20,7 +21,10 @@ ])) @_js-test (#set! tag js-test)) -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -31,7 +35,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/typescript/highlights.scm b/crates/grammars/src/typescript/highlights.scm index 0213adb10f61a4..c3ecf179465d4a 100644 --- a/crates/grammars/src/typescript/highlights.scm +++ b/crates/grammars/src/typescript/highlights.scm @@ -193,6 +193,26 @@ (shorthand_property_identifier_pattern) ]) @variable.parameter)) +(required_parameter + (_ + (object_assignment_pattern + left: (shorthand_property_identifier_pattern) @variable.parameter))) + +(required_parameter + (_ + (assignment_pattern + left: (identifier) @variable.parameter))) + +(optional_parameter + (_ + (object_assignment_pattern + left: (shorthand_property_identifier_pattern) @variable.parameter))) + +(optional_parameter + (_ + (assignment_pattern + left: (identifier) @variable.parameter))) + (optional_parameter (identifier) @variable.parameter) diff --git a/crates/grammars/src/typescript/indents.scm b/crates/grammars/src/typescript/indents.scm index 2715d2567194f0..3dc7d76813c8f8 100644 --- a/crates/grammars/src/typescript/indents.scm +++ b/crates/grammars/src/typescript/indents.scm @@ -5,12 +5,26 @@ (lexical_declaration) (variable_declaration) (assignment_expression) - ; below handled by `(_ "{" "}" @end) @indent` - ; (if_statement) - ; (for_statement) - ; (while_statement) ] @indent +; Indent a braceless body (`if (x)\n y()`). When the body is a `{}` block the +; `@end` stops the range before the brace so the block rule handles it instead, +; which keeps Allman-style braces unindented. +(if_statement + consequence: (statement_block)? @end) @indent + +(else_clause + (statement_block)? @end) @indent + +(for_statement + body: (statement_block)? @end) @indent + +(for_in_statement + body: (statement_block)? @end) @indent + +(while_statement + body: (statement_block)? @end) @indent + (_ "[" "]" @end) @indent diff --git a/crates/grammars/src/typescript/injections.scm b/crates/grammars/src/typescript/injections.scm index a8cf9a41b5f90a..19ffe6979183c2 100644 --- a/crates/grammars/src/typescript/injections.scm +++ b/crates/grammars/src/typescript/injections.scm @@ -197,3 +197,25 @@ ]) (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") (#set! injection.language "css")) + +; '/* glsl */' or '/*glsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*glsl\\s*\\*\\/") + (#set! injection.language "glsl")) + +; '/* wgsl */' or '/*wgsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*wgsl\\s*\\*\\/") + (#set! injection.language "WGSL/WESL")) diff --git a/crates/grammars/src/typescript/outline.scm b/crates/grammars/src/typescript/outline.scm index 2c08c30ad587ff..19099b0076a87e 100644 --- a/crates/grammars/src/typescript/outline.scm +++ b/crates/grammars/src/typescript/outline.scm @@ -175,6 +175,7 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -194,7 +195,10 @@ (identifier) @name ]))) @item -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -205,7 +209,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/typescript/runnables.scm b/crates/grammars/src/typescript/runnables.scm index 38fee610e85f2a..b2321b816ca41e 100644 --- a/crates/grammars/src/typescript/runnables.scm +++ b/crates/grammars/src/typescript/runnables.scm @@ -1,5 +1,6 @@ ; Add support for (node:test, bun:test, Jest and Deno.test) runnable ; Function expression that has `it`, `test` or `describe` as the function name +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -20,7 +21,10 @@ ])) @_js-test (#set! tag js-test)) -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -31,7 +35,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs index 2970d118ad3265..bbb85c524912b6 100644 --- a/crates/http_client/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -11,6 +11,10 @@ use sha2::{Digest, Sha256}; use crate::{HttpClient, github::AssetKind}; +fn sha256_matches(actual: &str, expected: &str) -> bool { + actual.eq_ignore_ascii_case(expected) +} + #[derive(serde::Deserialize, serde::Serialize, Debug)] pub struct GithubBinaryMetadata { pub metadata_version: u64, @@ -104,7 +108,7 @@ pub async fn download_server_raw_binary( if let Some(expected_sha_256) = digest { anyhow::ensure!( - asset_sha_256 == expected_sha_256, + sha256_matches(&asset_sha_256, expected_sha_256), "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}", ); } @@ -150,7 +154,7 @@ async fn extract_to_staging( let asset_sha_256 = format!("{:x}", writer.hasher.finalize()); anyhow::ensure!( - asset_sha_256 == expected_sha_256, + sha256_matches(&asset_sha_256, expected_sha_256), "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}", ); writer @@ -403,12 +407,12 @@ mod tests { } #[test] - fn downloads_raw_binary_into_destination_dir() { + fn downloads_raw_binary_with_uppercase_digest_into_destination_dir() { futures::executor::block_on(async { let temp_dir = tempfile::tempdir().unwrap(); let destination_path = temp_dir.path().join("v_1"); let contents = b"#!/bin/sh\necho hello\n".to_vec(); - let expected_sha_256 = format!("{:x}", Sha256::digest(&contents)); + let expected_sha_256 = format!("{:X}", Sha256::digest(&contents)); let client = StaticResponseClient { body: contents }; download_server_raw_binary( @@ -463,4 +467,66 @@ mod tests { assert_eq!(leftover_entries, 0, "staging directory should be removed"); }); } + + #[test] + fn downloads_archive_with_uppercase_digest_and_extracts_contents() { + futures::executor::block_on(async { + let archive = vec![ + 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x00, + 0x86, 0xa6, 0x10, 0x36, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x50, 0x4b, + 0x01, 0x02, 0x14, 0x03, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x00, + 0x86, 0xa6, 0x10, 0x36, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x33, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, + 0x00, + ]; + let expected_sha_256 = format!("{:X}", Sha256::digest(&archive)); + let client = StaticResponseClient { body: archive }; + let temp_dir = tempfile::tempdir().unwrap(); + let destination_path = temp_dir.path().join("v_1"); + + download_server_binary( + &client, + "https://example.com/agent.zip", + Some(&expected_sha_256), + &destination_path, + AssetKind::Zip, + ) + .await + .unwrap(); + + assert_eq!( + std::fs::read(destination_path.join("agent")).unwrap(), + b"hello" + ); + }); + } + + #[test] + fn archive_digest_mismatch_prevents_extraction_and_cleans_up_staging() { + futures::executor::block_on(async { + let temp_dir = tempfile::tempdir().unwrap(); + let destination_path = temp_dir.path().join("v_1"); + let client = StaticResponseClient { + body: b"not an archive".to_vec(), + }; + + let error = download_server_binary( + &client, + "https://example.com/agent.zip", + Some("0000000000000000000000000000000000000000000000000000000000000000"), + &destination_path, + AssetKind::Zip, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("SHA-256 mismatch")); + assert!(!destination_path.exists()); + let leftover_entries = std::fs::read_dir(temp_dir.path()).unwrap().count(); + assert_eq!(leftover_entries, 0, "staging directory should be removed"); + }); + } } diff --git a/crates/http_proxy/src/http_proxy.rs b/crates/http_proxy/src/http_proxy.rs index 1739d846c193cb..efed6140970d68 100644 --- a/crates/http_proxy/src/http_proxy.rs +++ b/crates/http_proxy/src/http_proxy.rs @@ -47,9 +47,11 @@ //! would see from a direct network failure, no proxy fingerprint. mod allowlist; +mod pinned_host; mod proxy; pub use allowlist::{Allowlist, HostPattern, HostPatternError}; +pub use pinned_host::{PinnedHost, PinnedHostError, is_forbidden_ip}; pub use proxy::{ DenyReason, ProxyConfig, ProxyEvent, ProxyHandle, RequestMethod, RequestOutcome, UpstreamProxy, }; diff --git a/crates/http_proxy/src/pinned_host.rs b/crates/http_proxy/src/pinned_host.rs new file mode 100644 index 00000000000000..9443e9629cb881 --- /dev/null +++ b/crates/http_proxy/src/pinned_host.rs @@ -0,0 +1,287 @@ +//! A resolved-and-vetted network destination, pinned to the exact IP addresses +//! that were checked. +//! +//! DNS rebinding / SSRF is a time-of-check-to-time-of-use hazard: code that +//! resolves a hostname, decides it's safe, and then hands the *hostname* back to +//! something that resolves it *again* has checked one answer and used another. A +//! hostname that the sandbox distrusts can exploit that window to point a +//! "granted" host at loopback, the LAN, or a cloud metadata endpoint. +//! +//! [`PinnedHost`] closes that window the way [`sandbox::HostFilesystemLocation`] +//! does for filesystem paths: construction resolves the name and vets every +//! address *once*, and the resulting value carries the checked addresses by +//! value. The type is deliberately opaque — it never hands back the hostname for +//! re-resolution — so the only way to "use" it is to connect to an address it +//! already vetted. Re-deriving a destination from the hostname requires reaching +//! for [`PinnedHost::untrusted_host_display`], whose name flags it as +//! display-only, so the type system nudges callers away from reintroducing the +//! TOCTOU. +//! +//! One `PinnedHost` describes **one** hostname's resolved addresses. A redirect +//! chain that visits several hosts produces one `PinnedHost` per hop, each +//! resolved and vetted independently at the moment its hop runs. + +use crate::allowlist::Allowlist; +use std::collections::HashSet; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs as _}; + +/// Why pinning a host failed. +#[derive(Debug, thiserror::Error)] +pub enum PinnedHostError { + /// DNS resolution itself failed (or the host is malformed). + #[error("resolving {host}:{port}: {source}")] + Resolve { + host: String, + port: u16, + source: std::io::Error, + }, + /// The host resolved, but to no addresses at all. + #[error("{host}:{port} did not resolve to any address")] + NoAddresses { host: String, port: u16 }, + /// Every resolved address was in loopback / private / link-local space, so + /// there is nothing safe to connect to (DNS-rebinding protection). + #[error( + "{host} resolved only to loopback/private/link-local addresses, \ + which the sandbox never reaches by hostname" + )] + AllAddressesForbidden { host: String }, +} + +/// A hostname whose DNS has been resolved and whose addresses have been vetted +/// against the forbidden-IP policy, pinned to those exact addresses. +/// +/// Construct with [`PinnedHost::resolve`] (applies the forbidden-IP filter) or +/// [`PinnedHost::resolve_allowing_any`] (skips it, for the "allow any host" +/// grant that means unrestricted egress). Use the pinned addresses via +/// [`PinnedHost::socket_addrs`]; the hostname is available only via +/// [`PinnedHost::untrusted_host_display`], which must never be fed back into a +/// resolver. +#[derive(Debug, Clone)] +pub struct PinnedHost { + /// The vetted addresses, deduplicated. A set rather than a list because DNS + /// order carries no meaning here and duplicate answers (or duplicates across + /// A/AAAA records) should collapse — what matters is the set of endpoints we + /// concluded are safe to reach. + addrs: HashSet, + /// The requested host, kept **only** for display in errors/UI. Never + /// consulted to connect — treat it as untrusted, attacker-influenced text. + untrusted_host_for_display: String, +} + +impl PinnedHost { + /// Resolve `host:port` and pin the subset of addresses that pass the + /// forbidden-IP filter. Fails if resolution fails, yields no addresses, or + /// yields only forbidden ones. + pub fn resolve(host: &str, port: u16) -> Result { + Self::resolve_inner(host, port, true) + } + + /// Resolve `host:port` and pin **every** resolved address without applying + /// the forbidden-IP filter. + /// + /// This is for the "allow any host" grant (`allow_all_hosts` / + /// [`Allowlist::allows_any`]), which is unrestricted egress by definition — + /// including the local network and metadata endpoints. Callers that don't + /// hold such a grant must use [`PinnedHost::resolve`]. + pub fn resolve_allowing_any(host: &str, port: u16) -> Result { + Self::resolve_inner(host, port, false) + } + + /// Resolve against an [`Allowlist`], choosing the filtered or unfiltered path + /// based on whether the allowlist grants arbitrary egress. + pub fn resolve_for_allowlist( + host: &str, + port: u16, + allowlist: &Allowlist, + ) -> Result { + Self::resolve_inner(host, port, !allowlist.allows_any()) + } + + fn resolve_inner(host: &str, port: u16, vet: bool) -> Result { + let resolved = + (host, port) + .to_socket_addrs() + .map_err(|source| PinnedHostError::Resolve { + host: host.to_string(), + port, + source, + })?; + + let mut addrs = HashSet::new(); + let mut saw_any = false; + for addr in resolved { + saw_any = true; + if vet && is_forbidden_ip(addr.ip()) { + continue; + } + addrs.insert(addr); + } + + if !saw_any { + return Err(PinnedHostError::NoAddresses { + host: host.to_string(), + port, + }); + } + if addrs.is_empty() { + return Err(PinnedHostError::AllAddressesForbidden { + host: host.to_string(), + }); + } + + Ok(Self { + addrs, + untrusted_host_for_display: host.to_string(), + }) + } + + /// The vetted addresses to connect to. Connecting to one of these — rather + /// than re-resolving the hostname — is what keeps the check and the use + /// pinned to the same answer. + pub fn socket_addrs(&self) -> impl ExactSizeIterator + '_ { + self.addrs.iter().copied() + } + + /// The requested host, for **display only** (errors, UI). This intentionally + /// returns the untrusted, as-requested hostname — never a vetted address. Do + /// not feed the result back into a resolver as if it identified this + /// destination. + pub fn untrusted_host_display(&self) -> &str { + &self.untrusted_host_for_display + } +} + +/// Whether a resolved address is in loopback / private / link-local space — +/// destinations a hostname allowlist must never reach. The OS sandbox already +/// blocks them for direct connections from the sandbox; code running outside the +/// sandbox (the proxy, the fetch tool) must not reopen them. +pub fn is_forbidden_ip(ip: IpAddr) -> bool { + // Escape hatch for the NixOS sandbox integration tests only: their echo + // servers live on the VM's private network, which this filter would + // otherwise reject. It is compiled in ONLY under the + // `nixos-integration-tests` feature (enabled via `sandbox/nixos-test` when + // building `bwrap_test_helper`), so in a real Zed build the env var has no + // effect and cannot disable DNS-rebinding/SSRF protection. + #[cfg(feature = "nixos-integration-tests")] + if std::env::var_os("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS").is_some() { + return false; + } + match ip { + IpAddr::V4(v4) => is_forbidden_ipv4(v4), + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + return is_forbidden_ipv4(v4); + } + v6.is_loopback() + || v6.is_unspecified() + // Link-local (fe80::/10) and unique-local (fc00::/7); the + // dedicated `is_unicast_link_local` / `is_unique_local` + // methods are not yet stable. + || (v6.segments()[0] & 0xffc0) == 0xfe80 + || (v6.segments()[0] & 0xfe00) == 0xfc00 + } + } +} + +fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool { + let octets = ip.octets(); + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() // includes 169.254.169.254 cloud metadata + || ip.is_unspecified() + || ip.is_broadcast() + // Shared address space (RFC 6598, 100.64.0.0/10): CGNAT, and notably + // Tailscale-style overlay networks. + || (octets[0] == 100 && (octets[1] & 0xc0) == 64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forbidden_ip_covers_v4_ranges() { + for ip in [ + "127.0.0.1", + "10.0.0.1", + "192.168.1.1", + "172.16.0.1", + "169.254.169.254", // cloud metadata + "0.0.0.0", + "255.255.255.255", + "100.64.0.1", // CGNAT / Tailscale + ] { + assert!( + is_forbidden_ip(ip.parse().unwrap()), + "expected {ip} to be forbidden" + ); + } + } + + #[test] + fn forbidden_ip_covers_v6_ranges() { + for ip in [ + "::1", // loopback + "::", // unspecified + "fe80::1", // link-local + "fc00::1", // unique-local + "::ffff:127.0.0.1", // IPv4-mapped loopback + "::ffff:10.0.0.1", // IPv4-mapped private + ] { + assert!( + is_forbidden_ip(ip.parse().unwrap()), + "expected {ip} to be forbidden" + ); + } + } + + #[test] + fn public_ips_are_allowed() { + for ip in [ + "93.184.215.14", + "8.8.8.8", + "2606:2800:220:1:248:1893:25c8:1946", + ] { + assert!( + !is_forbidden_ip(ip.parse().unwrap()), + "expected {ip} to be allowed" + ); + } + } + + #[test] + fn resolve_pins_public_literal_addresses() { + // An IP literal "resolves" to itself, so this exercises the vetting and + // pinning without depending on real DNS. + let pinned = PinnedHost::resolve("93.184.215.14", 443).expect("public IP should pin"); + let addrs: HashSet = pinned.socket_addrs().collect(); + assert_eq!(addrs, HashSet::from(["93.184.215.14:443".parse().unwrap()])); + assert_eq!(pinned.untrusted_host_display(), "93.184.215.14"); + } + + #[test] + fn resolve_rejects_forbidden_literal_address() { + let error = PinnedHost::resolve("127.0.0.1", 80).expect_err("loopback must be rejected"); + assert!(matches!( + error, + PinnedHostError::AllAddressesForbidden { .. } + )); + } + + #[test] + fn resolve_allowing_any_keeps_forbidden_addresses() { + let pinned = + PinnedHost::resolve_allowing_any("127.0.0.1", 80).expect("allow-any keeps loopback"); + let addrs: HashSet = pinned.socket_addrs().collect(); + assert_eq!(addrs, HashSet::from(["127.0.0.1:80".parse().unwrap()])); + } + + #[test] + fn resolve_deduplicates_addresses() { + // `localhost` typically resolves to both 127.0.0.1 and ::1; under + // allow-any both are kept and distinct. This mainly guards that the set + // collapses exact duplicates rather than the specific addresses. + let pinned = PinnedHost::resolve_allowing_any("127.0.0.1", 80).unwrap(); + assert_eq!(pinned.socket_addrs().count(), 1); + } +} diff --git a/crates/http_proxy/src/proxy/connection.rs b/crates/http_proxy/src/proxy/connection.rs index 06ae8ea73d02d2..98858357c30956 100644 --- a/crates/http_proxy/src/proxy/connection.rs +++ b/crates/http_proxy/src/proxy/connection.rs @@ -11,13 +11,14 @@ //! client sends on it cannot escape the policy decision. Per-TCP-connection //! event granularity, by design. +use crate::pinned_host::{PinnedHost, PinnedHostError}; use crate::proxy::{ DenyReason, ProxyEvent, RequestMethod, RequestOutcome, RuntimeState, UpstreamProxy, }; use anyhow::{Context, Result, anyhow, bail}; use base64::Engine as _; use std::io::{Read, Write}; -use std::net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpStream, ToSocketAddrs as _}; +use std::net::{IpAddr, Shutdown, SocketAddr, TcpStream, ToSocketAddrs as _}; #[cfg(unix)] use std::os::unix::net::UnixStream; use std::sync::Arc; @@ -428,8 +429,9 @@ fn policy_denial(host: &str, port: u16, state: &RuntimeState) -> Option), + /// Connect directly to one of the addresses pinned when the host was + /// resolved and vetted. + Direct(PinnedHost), /// Tunnel through the upstream proxy with a CONNECT handshake. ViaUpstream(UpstreamProxy), } @@ -460,76 +462,17 @@ fn plan_route(host: &str, port: u16, state: &RuntimeState) -> Result = (host, port) - .to_socket_addrs() - .map_err(|error| RouteFailure::Error(anyhow!("resolving {host}:{port}: {error}")))? - .collect(); - if resolved.is_empty() { - return Err(RouteFailure::Error(anyhow!( - "{host}:{port} did not resolve to any address" - ))); - } - - let vetted: Vec = if state.allowlist.allows_any() { - resolved - } else { - resolved - .into_iter() - .filter(|addr| !is_forbidden_ip(addr.ip())) - .collect() - }; - if vetted.is_empty() { - return Err(RouteFailure::Denied(DenyReason::ResolvedToForbiddenIp { - host: host.to_string(), - })); - } - Ok(Route::Direct(vetted)) -} - -/// Whether a resolved address is in loopback / private / link-local space — -/// destinations a hostname allowlist must never reach. The Seatbelt rule -/// already blocks them for direct connections from the sandbox; the proxy -/// (which runs outside the sandbox) must not reopen them. -fn is_forbidden_ip(ip: IpAddr) -> bool { - // Escape hatch for the NixOS sandbox integration tests only: their echo - // servers live on the VM's private network, which this filter would - // otherwise reject. It is compiled in ONLY under the - // `nixos-integration-tests` feature (enabled via `sandbox/nixos-test` when - // building `bwrap_test_helper`), so in a real Zed build the env var has no - // effect and cannot disable DNS-rebinding/SSRF protection. - #[cfg(feature = "nixos-integration-tests")] - if std::env::var_os("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS").is_some() { - return false; - } - match ip { - IpAddr::V4(v4) => is_forbidden_ipv4(v4), - IpAddr::V6(v6) => { - if let Some(v4) = v6.to_ipv4_mapped() { - return is_forbidden_ipv4(v4); - } - v6.is_loopback() - || v6.is_unspecified() - // Link-local (fe80::/10) and unique-local (fc00::/7); the - // dedicated `is_unicast_link_local` / `is_unique_local` - // methods are not yet stable. - || (v6.segments()[0] & 0xffc0) == 0xfe80 - || (v6.segments()[0] & 0xfe00) == 0xfc00 + match PinnedHost::resolve_for_allowlist(host, port, &state.allowlist) { + Ok(pinned) => Ok(Route::Direct(pinned)), + Err(PinnedHostError::AllAddressesForbidden { host }) => { + Err(RouteFailure::Denied(DenyReason::ResolvedToForbiddenIp { + host, + })) } + Err(error) => Err(RouteFailure::Error(anyhow!(error))), } } -fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool { - let octets = ip.octets(); - ip.is_loopback() - || ip.is_private() - || ip.is_link_local() // includes 169.254.169.254 cloud metadata - || ip.is_unspecified() - || ip.is_broadcast() - // Shared address space (RFC 6598, 100.64.0.0/10): CGNAT, and notably - // Tailscale-style overlay networks. - || (octets[0] == 100 && (octets[1] & 0xc0) == 64) -} - fn handle_connect( mut client: ClientStream, host: String, @@ -726,7 +669,13 @@ fn handle_http_forward( /// handshake with the CONNECT path. fn open_route(route: &Route, host: &str, port: u16) -> Result<(TcpStream, Vec)> { match route { - Route::Direct(addrs) => Ok((connect_to_any(addrs, host, port)?, Vec::new())), + Route::Direct(pinned) => { + // Connect to the addresses pinned when the host was vetted, never + // re-resolving `host` here — that re-resolution is exactly the + // DNS-rebinding window `PinnedHost` exists to close. + let addrs: Vec = pinned.socket_addrs().collect(); + Ok((connect_to_any(&addrs, host, port)?, Vec::new())) + } Route::ViaUpstream(upstream) => connect_via_upstream(host, port, upstream), } } @@ -984,7 +933,8 @@ mod tests { fn plan_route_allows_loopback_when_allowlist_allows_any() { let state = runtime_state(Allowlist::any()); match plan_route("localhost", 80, &state) { - Ok(Route::Direct(addrs)) => { + Ok(Route::Direct(pinned)) => { + let addrs: Vec = pinned.socket_addrs().collect(); assert!(!addrs.is_empty()); assert!(addrs.iter().all(|addr| addr.ip().is_loopback())); } @@ -1077,35 +1027,7 @@ mod tests { assert!(!is_ip_literal("localhost")); } - #[test] - fn forbidden_ips_cover_local_space() { - for forbidden in [ - "127.0.0.1", - "10.1.2.3", - "172.16.0.1", - "192.168.1.1", - "169.254.169.254", - "100.100.1.1", - "0.0.0.0", - "::1", - "::", - "fe80::1", - "fd00::1", - "::ffff:127.0.0.1", - "::ffff:10.0.0.1", - ] { - assert!( - is_forbidden_ip(forbidden.parse().unwrap()), - "{forbidden} should be forbidden" - ); - } - for public in ["140.82.112.3", "8.8.8.8", "2606:4700::6810:84e5"] { - assert!( - !is_forbidden_ip(public.parse().unwrap()), - "{public} should be allowed" - ); - } - } + // Forbidden-IP range coverage lives with the logic in `pinned_host.rs`. #[test] fn parsed_request_recognizes_connect() { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index e0bc10ad258519..950d4623698851 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -131,8 +131,10 @@ pub enum IconName { FileDoc, FileGeneric, FileGit, + FileIgnored, FileLock, FileMarkdown, + FileMultiple, FileRust, FileTextFilled, FileTextOutlined, @@ -180,7 +182,6 @@ pub enum IconName { Link, Linux, ListCollapse, - ListFilter, ListTodo, ListTree, ListX, @@ -196,6 +197,7 @@ pub enum IconName { MicMute, Minimize, Notepad, + OnCall, Option, PageDown, PageUp, @@ -237,7 +239,6 @@ pub enum IconName { SignalLow, SignalMedium, Slash, - Sliders, Sourcehut, Space, Sparkle, @@ -285,6 +286,7 @@ pub enum IconName { TriangleRight, Undo, Unpin, + UserArrowUp, UserCheck, UserGroup, UserRoundPen, @@ -311,3 +313,45 @@ impl IconName { format!("icons/{file_stem}.svg").into() } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use strum::{IntoEnumIterator as _, ParseError}; + + use crate::IconName; + + #[test] + fn test_all_icons_exist() { + let asset_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + for icon in IconName::iter() { + let icon_path = asset_path.join(&*icon.path()); + assert!( + icon_path.exists(), + "Icon {icon:?} does not exist at {icon_path:?}", + ); + } + } + + #[test] + fn test_no_dangling_icons() -> Result<(), ParseError> { + let icons_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons"); + + for entry in std::fs::read_dir(&icons_dir).expect("failed to read icons directory") { + let path = entry.expect("failed to read icons directory entry").path(); + if path.extension().is_none_or(|extension| extension != "svg") { + continue; + } + let file_stem = path + .file_stem() + .and_then(|file_stem| file_stem.to_str()) + .expect("icon file name is not valid UTF-8"); + + file_stem.parse::()?; + } + + Ok(()) + } +} diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index d881bcc2561fa6..3dc434deb74f5b 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -586,7 +586,7 @@ impl Item for ImageView { } fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String { - let mut path = image.file.path().clone(); + let mut path = image.file.path().to_rel_path_buf(); if project.visible_worktrees(cx).count() > 1 && let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx) { diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 0bb66ab988c565..44d53453ba5f61 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1436,8 +1436,15 @@ impl KeymapEditor { self.table_interaction_state.read(cx).scroll_offset(), )); let keyboard_mapper = cx.keyboard_mapper().clone(); + let deprecated_aliases = cx.deprecated_actions_to_preferred_actions().clone(); cx.spawn(async move |_, _| { - remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await + remove_keybinding( + to_remove, + &fs, + keyboard_mapper.as_ref(), + &deprecated_aliases, + ) + .await }) .detach_and_notify_err(self.workspace.clone(), window, cx); } @@ -1671,7 +1678,7 @@ impl KeymapEditor { y: px(2.0), }) .trigger_with_tooltip( - IconButton::new("KeymapEditorFilterMenuButton", IconName::Sliders) + IconButton::new("KeymapEditorFilterMenuButton", IconName::Filter) .icon_size(IconSize::Small) .when( self.keybinding_conflict_state.any_user_binding_conflicts(), @@ -1694,7 +1701,7 @@ impl KeymapEditor { menu.toggleable_entry( name, toggled, - IconPosition::End, + IconPosition::Start, action.as_ref().map(|a| a.boxed_clone()), move |window, cx| { window.focus(&focus_handle, cx); @@ -2011,7 +2018,6 @@ impl Render for KeymapEditor { .child( h_flex() .gap_2() - .items_center() .child( h_flex() .key_context({ @@ -2022,9 +2028,7 @@ impl Render for KeymapEditor { .flex_1() .min_w_0() .h_8() - .pl_2() - .pr_1() - .py_1() + .px_2() .border_1() .border_color(theme.colors().border) .rounded_md() @@ -2034,7 +2038,9 @@ impl Render for KeymapEditor { h_flex() .gap_1() .flex_none() - .items_center() + // Make sure this min-width value aligns with the spacer + // div in the keystroke search input + .min_w_80() .child( IconButton::new( "KeymapEditorKeystrokeSearchButton", @@ -2068,7 +2074,6 @@ impl Render for KeymapEditor { ) .child( Button::new("edit-in-json", "Edit in JSON") - .style(ButtonStyle::Subtle) .key_binding( ui::KeyBinding::for_action_in(&zed_actions::OpenKeymapFile, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(10.))), @@ -2103,7 +2108,7 @@ impl Render for KeymapEditor { h_flex() .gap_2() .child(self.keystroke_editor.clone()) - .child(div().min_w_96()), // Spacer div to align with the search input + .child(div().min_w_80()), // Spacer div to align with the search input ) }, ), @@ -2839,6 +2844,7 @@ impl KeybindingEditorModal { let create = self.creating; let keyboard_mapper = cx.keyboard_mapper().clone(); + let deprecated_aliases = cx.deprecated_actions_to_preferred_actions().clone(); let action_name = self .get_selected_action_name(cx) @@ -2869,6 +2875,7 @@ impl KeybindingEditorModal { new_action_args.as_deref(), &fs, keyboard_mapper.as_ref(), + &deprecated_aliases, ) .await { @@ -3607,6 +3614,7 @@ async fn save_keybinding_update( new_args: Option<&str>, fs: &Arc, keyboard_mapper: &dyn PlatformKeyboardMapper, + deprecated_aliases: &HashMap<&'static str, &'static str>, ) -> anyhow::Result<()> { let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await @@ -3656,8 +3664,9 @@ async fn save_keybinding_update( keymap_contents, tab_size, keyboard_mapper, + deprecated_aliases, ) - .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; + .map_err(|err| err.context("Could not save updated keybinding"))?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), @@ -3678,6 +3687,7 @@ async fn remove_keybinding( existing: ProcessedBinding, fs: &Arc, keyboard_mapper: &dyn PlatformKeyboardMapper, + deprecated_aliases: &HashMap<&'static str, &'static str>, ) -> anyhow::Result<()> { let Some(keystrokes) = existing.keystrokes() else { anyhow::bail!("Cannot remove a keybinding that does not exist"); @@ -3707,6 +3717,7 @@ async fn remove_keybinding( keymap_contents, tab_size, keyboard_mapper, + deprecated_aliases, ) .context("Failed to update keybinding")?; fs.write( @@ -4015,6 +4026,204 @@ mod persistence { #[cfg(test)] mod tests { use super::*; + use fs::FakeFs; + use gpui::{TestAppContext, VisualTestContext}; + use project::Project; + use serde_json::json; + use settings::KeymapFileLoadResult; + use workspace::{AppState, MultiWorkspace}; + + async fn reload_keymap_from_file(fs: &Arc, cx: &mut TestAppContext) { + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + cx.update(|cx| { + let mut key_bindings = match KeymapFile::load(&content, cx) { + KeymapFileLoadResult::Success { key_bindings } => key_bindings, + KeymapFileLoadResult::SomeFailedToLoad { error_message, .. } => { + panic!("keymap failed to load: {error_message:?}") + } + KeymapFileLoadResult::JsonParseFailure { error } => { + panic!("keymap json parse failure: {error}") + } + }; + cx.clear_key_bindings(); + for key_binding in &mut key_bindings { + key_binding.set_meta(KeybindSource::User.meta()); + } + cx.bind_keys(key_bindings); + KeymapEventChannel::trigger_keymap_changed(cx); + }); + } + + async fn setup_keymap_editor( + cx: &mut TestAppContext, + keymap_content: &str, + ) -> (Arc, Entity, VisualTestContext) { + cx.update(|cx| { + let _state = AppState::test(cx); + editor::init(cx); + cx.set_global(KeymapEventChannel::new()); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + paths::config_dir(), + json!({ "keymap.json": keymap_content }), + ) + .await; + + reload_keymap_from_file(&fs, cx).await; + + let project = Project::test(fs.clone(), [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + let keymap_editor = cx + .update(|window, cx| cx.new(|cx| KeymapEditor::new(workspace.downgrade(), window, cx))); + cx.run_until_parked(); + (fs, keymap_editor, cx) + } + + fn visible_rows_for_action(editor: &KeymapEditor, action_name: &str) -> Vec { + editor + .matches + .iter() + .enumerate() + .filter(|(_, string_match)| { + let binding = &editor.keybindings[string_match.candidate_id]; + binding.action().name == action_name && binding.keystrokes().is_some() + }) + .map(|(index, _)| index) + .collect() + } + + #[gpui::test] + async fn test_delete_one_of_two_identical_user_bindings(cx: &mut TestAppContext) { + let keymap_content = r#"[ + { + "bindings": { + "alt-cmd-shift-c": "zed::OpenKeymap" + } + }, + { + "bindings": { + "alt-cmd-shift-c": "zed::OpenKeymap" + } + } +]"#; + let (fs, keymap_editor, mut cx) = setup_keymap_editor(cx, keymap_content).await; + let cx = &mut cx; + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "zed::OpenKeymap") + }); + assert_eq!( + rows.len(), + 2, + "expected the two duplicate bindings to show as two rows" + ); + + keymap_editor.update_in(cx, |editor, window, cx| { + editor.selected_index = Some(rows[1]); + editor.delete_binding(&DeleteBinding, window, cx); + }); + cx.run_until_parked(); + + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + assert_eq!( + content.matches("alt-cmd-shift-c").count(), + 1, + "expected exactly one binding remaining in the keymap file, got:\n{content}" + ); + + // Simulate the keymap file watcher reacting to the change. + reload_keymap_from_file(&fs, cx).await; + cx.run_until_parked(); + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "zed::OpenKeymap") + }); + assert_eq!(rows.len(), 1, "expected one row remaining after deletion"); + } + + // Regression test: one of the two entries in the keymap file uses a + // deprecated alias of the action (`editor::CopyRelativePath` instead of + // `workspace::CopyRelativePath`). Both rows display identically in the + // keymap editor (aliases resolve to the canonical action on load), but + // deletion targets the canonical action name, so `KeymapFile::update_keybinding` + // used to never find the alias entry, making it impossible to delete. + #[gpui::test] + async fn test_delete_binding_with_deprecated_action_alias(cx: &mut TestAppContext) { + let keymap_content = r#"[ + { + "bindings": { + "alt-cmd-shift-c": "editor::CopyRelativePath" + } + }, + { + "bindings": { + "alt-cmd-shift-c": "workspace::CopyRelativePath" + } + } +]"#; + let (fs, keymap_editor, mut cx) = setup_keymap_editor(cx, keymap_content).await; + let cx = &mut cx; + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "workspace::CopyRelativePath") + }); + assert_eq!( + rows.len(), + 2, + "both the alias and the canonical entry should show as (identical) rows" + ); + + // Delete the first row. Both rows report the canonical action name, so + // `find_binding` matches the canonical file entry and removes it. + keymap_editor.update_in(cx, |editor, window, cx| { + editor.selected_index = Some(rows[0]); + editor.delete_binding(&DeleteBinding, window, cx); + }); + cx.run_until_parked(); + + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + assert_eq!( + content.matches("alt-cmd-shift-c").count(), + 1, + "first deletion should remove one of the two entries, got:\n{content}" + ); + + // Simulate the keymap file watcher reacting to the change. + reload_keymap_from_file(&fs, cx).await; + cx.run_until_parked(); + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "workspace::CopyRelativePath") + }); + assert_eq!( + rows.len(), + 1, + "one row should remain after the first deletion" + ); + + // Delete the remaining row (the alias entry). `find_binding` must + // resolve the deprecated alias in the file to the canonical action + // name to find and remove it. + keymap_editor.update_in(cx, |editor, window, cx| { + editor.selected_index = Some(rows[0]); + editor.delete_binding(&DeleteBinding, window, cx); + }); + cx.run_until_parked(); + + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + assert_eq!( + content.matches("alt-cmd-shift-c").count(), + 0, + "second deletion should remove the remaining (alias) entry, got:\n{content}" + ); + } #[test] fn normalized_ctx_cmp() { diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 58a7517d2cb925..df493c393e7b48 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -68,7 +68,7 @@ tree-sitter-md = { workspace = true, optional = true } tree-sitter-python = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } -tree-sitter.workspace = true +tree-sitter = { workspace = true, features = ["wasm"] } unicase = "2.6" util.workspace = true watch.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 413c50d2627914..c37065146f25bf 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -2163,6 +2163,11 @@ impl Buffer { for row in row_range.skip(1) { indent_sizes.entry(row).or_insert_with(|| { let mut size = snapshot.indent_size_for_line(row); + // A line with no indentation has an arbitrary + // indent kind, so it can adopt the new kind. + if size.len == 0 { + size.kind = new_indent.kind; + } if size.kind == new_indent.kind { match delta.cmp(&0) { Ordering::Greater => size.len += delta as u32, @@ -2279,14 +2284,21 @@ impl Buffer { }) } - /// Spawns a background task that searches the buffer for any whitespace - /// at the ends of a lines, and returns a `Diff` that removes that whitespace. - pub fn remove_trailing_whitespace(&self, cx: &App) -> Task { + /// Spawns a background task that returns a `Diff` removing trailing whitespace from line ends. + /// + /// When `modified_rows` is `Some`, only lines whose row falls within one of the given ranges + /// are trimmed; when it is `None`, the whole buffer is scanned. + pub fn remove_trailing_whitespace( + &self, + modified_rows: Option<&[Range]>, + cx: &App, + ) -> Task { let old_text = self.as_rope().clone(); let line_ending = self.line_ending(); let base_version = self.version(); + let modified_rows = modified_rows.map(|rows| rows.to_vec()); cx.background_spawn(async move { - let ranges = trailing_whitespace_ranges(&old_text); + let ranges = trailing_whitespace_ranges(&old_text, modified_rows.as_deref()); let empty = Arc::::from(""); Diff { base_version, @@ -2299,28 +2311,60 @@ impl Buffer { }) } - /// Ensures that the buffer ends with a single newline character, and - /// no other whitespace. Skips if the buffer is empty. - pub fn ensure_final_newline(&mut self, cx: &mut Context) { + /// Returns a `Diff` ensuring the buffer ends with a trailing newline. + /// + /// When `modified_rows` is `None`, the whole buffer is considered: trailing whitespace and + /// blank lines at the end of the file are collapsed into a single newline. + /// + /// When `modified_rows` is `Some`, the operation is scoped to a "Format Selection": a single + /// newline is appended only when the last line is non-empty and its row falls within one of + /// the ranges. Trailing blank lines are left intact so that formatting a selection cannot + /// delete unselected rows. + pub fn ensure_final_newline(&self, modified_rows: Option<&[Range]>) -> Diff { let len = self.len(); - if len == 0 { - return; - } - let mut offset = len; - for chunk in self.as_rope().reversed_chunks_in_range(0..len) { - let non_whitespace_len = chunk - .trim_end_matches(|c: char| c.is_ascii_whitespace()) - .len(); - offset -= chunk.len(); - offset += non_whitespace_len; - if non_whitespace_len != 0 { - if offset == len - 1 && chunk.get(non_whitespace_len..) == Some("\n") { - return; + let line_ending = self.line_ending(); + let base_version = self.version(); + let newline = Arc::::from("\n"); + + let edits = if len == 0 { + Vec::new() + } else if let Some(modified_rows) = modified_rows { + let max_point = self.max_point(); + let last_line_is_empty = max_point.column == 0; + let last_row_is_modified = modified_rows + .iter() + .any(|range| range.contains(&max_point.row)); + if last_line_is_empty || !last_row_is_modified { + Vec::new() + } else { + Vec::from([(len..len, newline)]) + } + } else { + let mut offset = len; + let mut already_normalized = false; + for chunk in self.as_rope().reversed_chunks_in_range(0..len) { + let non_whitespace_len = chunk + .trim_end_matches(|c: char| c.is_ascii_whitespace()) + .len(); + offset -= chunk.len(); + offset += non_whitespace_len; + if non_whitespace_len != 0 { + already_normalized = + offset == len - 1 && chunk.get(non_whitespace_len..) == Some("\n"); + break; } - break; } + if already_normalized { + Vec::new() + } else { + Vec::from([(offset..len, newline)]) + } + }; + Diff { + base_version, + line_ending, + edits, } - self.edit([(offset..len, "\n")], None, cx); } /// Applies a diff to the buffer. If the buffer has changed since the given diff was @@ -6133,10 +6177,24 @@ impl CharClassifier { /// /// This could also be done with a regex search, but this implementation /// avoids copying text. -pub fn trailing_whitespace_ranges(rope: &Rope) -> Vec> { +/// Returns the byte ranges of trailing whitespace at the end of each line. +/// +/// When `row_ranges` is `Some`, only lines whose row falls within one of the ranges are +/// included. The single pass keeps filtering cheap, avoiding collecting every range up front. +pub(crate) fn trailing_whitespace_ranges( + rope: &Rope, + row_ranges: Option<&[Range]>, +) -> Vec> { let mut ranges = Vec::new(); + let is_row_included = |row: u32| match row_ranges { + Some(row_ranges) => row_ranges.iter().any(|range| range.contains(&row)), + None => true, + }; + let mut offset = 0; + let mut current_row: u32 = 0; + let mut prev_row: Option = None; let mut prev_chunk_trailing_whitespace_range = 0..0; for chunk in rope.chunks() { let mut prev_line_trailing_whitespace_range = 0..0; @@ -6148,19 +6206,24 @@ pub fn trailing_whitespace_ranges(rope: &Rope) -> Vec> { if i == 0 && trimmed_line_len == 0 { trailing_whitespace_range.start = prev_chunk_trailing_whitespace_range.start; } - if !prev_line_trailing_whitespace_range.is_empty() { - ranges.push(prev_line_trailing_whitespace_range); + if let Some(row) = prev_row { + if !prev_line_trailing_whitespace_range.is_empty() && is_row_included(row) { + ranges.push(prev_line_trailing_whitespace_range); + } } + prev_row = Some(current_row); offset = line_end_offset + 1; + current_row += 1; prev_line_trailing_whitespace_range = trailing_whitespace_range; } offset -= 1; + current_row -= 1; prev_chunk_trailing_whitespace_range = prev_line_trailing_whitespace_range; } - if !prev_chunk_trailing_whitespace_range.is_empty() { + if !prev_chunk_trailing_whitespace_range.is_empty() && is_row_included(current_row) { ranges.push(prev_chunk_trailing_whitespace_range); } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 9c3964d29cec36..e398f135446c78 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -610,7 +610,7 @@ async fn test_normalize_whitespace(cx: &mut gpui::TestAppContext) { // Spawn a task to format the buffer's whitespace. // Pause so that the formatting task starts running. - let format = buffer.update(cx, |buffer, cx| buffer.remove_trailing_whitespace(cx)); + let format = buffer.update(cx, |buffer, cx| buffer.remove_trailing_whitespace(None, cx)); yield_now().await; // Edit the buffer while the normalization task is running. @@ -2219,6 +2219,38 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) { }); } +#[gpui::test] +fn test_autoindent_block_mode_with_hard_tabs(cx: &mut App) { + init_settings(cx, |settings| { + settings.defaults.hard_tabs = Some(true); + }); + + cx.new(|cx| { + let text = "fn a() {\n\tb();\n}"; + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); + + // Insert a block whose indentation mixes tab-indented lines with + // lines that have no leading whitespace, like a snippet body. + let inserted_text = "if c {\n\td();\n}\n"; + buffer.edit( + [(Point::new(2, 0)..Point::new(2, 0), inserted_text)], + Some(AutoindentMode::Block { + original_indent_columns: Vec::new(), + }), + cx, + ); + + // All of the block's lines are indented, including the ones that + // originally had no indentation. + assert_eq!( + buffer.text(), + "fn a() {\n\tb();\n\tif c {\n\t\td();\n\t}\n}" + ); + + buffer + }); +} + #[gpui::test] fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) { init_settings(cx, |_| {}); @@ -2988,6 +3020,104 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) { }); } +#[gpui::test] +async fn test_markdown_inline_html_highlighting(cx: &mut TestAppContext) { + let markdown_language = markdown_lang(); + let markdown_inline_language = Arc::new( + Language::new( + LanguageConfig { + name: "markdown-inline".into(), + grammar: Some("markdown-inline".into()), + ..Default::default() + }, + Some(tree_sitter_md::INLINE_LANGUAGE.into()), + ) + .with_highlights_query(include_str!( + "../../grammars/src/markdown-inline/highlights.scm" + )) + .unwrap() + .with_injection_query(include_str!( + "../../grammars/src/markdown-inline/injections.scm" + )) + .unwrap(), + ); + let html_language = Arc::new( + Language::new( + LanguageConfig { + name: "HTML".into(), + ..Default::default() + }, + Some(tree_sitter_html::LANGUAGE.into()), + ) + .with_highlights_query("(comment) @comment (tag_name) @tag") + .unwrap(), + ); + let syntax_theme = SyntaxTheme::new([ + ("comment".to_string(), gpui::rgba(0xffffffff).into()), + ("tag".to_string(), gpui::rgba(0xff0000ff).into()), + ]); + markdown_language.set_theme(&syntax_theme); + markdown_inline_language.set_theme(&syntax_theme); + html_language.set_theme(&syntax_theme); + let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor.clone())); + language_registry.add(markdown_language.clone()); + language_registry.add(markdown_inline_language); + language_registry.add(html_language); + + let text = "\n\n\ + Annotation in the middle \n\n\ + An inline comment can span within a paragraph.\n\n\ + Ordinary inline HTML: emphasized."; + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local(text, cx); + buffer.set_language_registry(language_registry); + buffer.set_language(Some(markdown_language), cx); + buffer + }); + + cx.run_until_parked(); + + buffer.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + let highlighted_text = |capture_name: &str| { + let highlight_id = syntax_theme + .highlight_id(capture_name) + .map(HighlightId::new); + assert!(highlight_id.is_some(), "{capture_name} not in test theme"); + let mut runs: Vec = Vec::new(); + let mut previous_chunk_matched = false; + let chunks = snapshot.chunks( + 0..snapshot.len(), + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ); + for chunk in chunks { + let chunk_matches = chunk.syntax_highlight_id == highlight_id; + if chunk_matches { + match runs.last_mut() { + Some(last_run) if previous_chunk_matched => last_run.push_str(chunk.text), + _ => runs.push(chunk.text.to_string()), + } + } + previous_chunk_matched = chunk_matches; + } + runs + }; + + assert_eq!( + highlighted_text("comment"), + vec![ + "", + "", + "", + ] + ); + assert_eq!(highlighted_text("tag"), vec!["em", "em"]); + }); +} + #[gpui::test] fn test_syntax_layer_at_for_combined_injections(cx: &mut App) { init_settings(cx, |_| {}); @@ -3831,7 +3961,7 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) { } let rope = Rope::from(text.as_str()); - let actual_ranges = trailing_whitespace_ranges(&rope); + let actual_ranges = trailing_whitespace_ranges(&rope, None); let expected_ranges = TRAILING_WHITESPACE_REGEX .find_iter(&text) .map(|m| m.range()) @@ -3844,6 +3974,228 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) { ); } +#[gpui::test(iterations = 500)] +fn test_trailing_whitespace_ranges_in_rows(mut rng: StdRng) { + let mut text = String::new(); + for _ in 0..rng.random_range(0..16) { + for _ in 0..rng.random_range(0..36) { + text.push(match rng.random_range(0..10) { + 0..=1 => ' ', + 3 => '\t', + _ => rng.random_range('a'..='z'), + }); + } + text.push('\n'); + } + match rng.random_range(0..10) { + 0..=1 => drop(text.pop()), + 2..=3 => text.push_str(&"\n".repeat(rng.random_range(1..5))), + _ => {} + } + + let rope = Rope::from(text.as_str()); + let all_ranges = trailing_whitespace_ranges(&rope, None); + let lines = text.split('\n').collect::>(); + + // A range covering every row must reproduce the unfiltered full scan exactly. + assert_eq!( + trailing_whitespace_ranges(&rope, Some(&[0..u32::MAX])), + all_ranges, + "full-coverage mismatch for lines:\n{lines:?}", + ); + + // For a random (possibly gappy) subset of rows, the filtered variant must equal + // the full scan restricted to ranges whose line is in the subset. + let max_row = rope.max_point().row; + let mut row_ranges = Vec::new(); + let mut row = 0; + while row <= max_row { + let span = rng.random_range(0..=3); + if span > 0 { + let end = (row + span).min(max_row + 1); + row_ranges.push(row..end); + row = end; + } + row += 1; + } + + let expected = all_ranges + .iter() + .filter(|range| { + let row = rope.offset_to_point(range.start).row; + row_ranges.iter().any(|r| r.contains(&row)) + }) + .cloned() + .collect::>(); + assert_eq!( + trailing_whitespace_ranges(&rope, Some(&row_ranges)), + expected, + "subset mismatch for ranges {row_ranges:?} and lines:\n{lines:?}", + ); +} + +#[gpui::test] +async fn test_trailing_whitespace_in_ranges(cx: &mut gpui::TestAppContext) { + // line 0: "zero" (no trailing whitespace) + // line 1: "one " (2 trailing spaces) + // line 2: "two" (no trailing whitespace) + // line 3: "three " (3 trailing spaces) + // line 4: "four" (no trailing whitespace) + // line 5: "five " (4 trailing spaces) + let text = ["zero", "one ", "two", "three ", "four", "five "].join("\n"); + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + // Only rows 1 and 5 are modified, so only those lines get cleaned; line 3 stays untouched. + let modified_rows = [1u32..2, 5..6]; + let diff = buffer + .update(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(Some(&modified_rows), cx) + }) + .await; + buffer.update(cx, |buffer, cx| { + buffer.apply_diff(diff, cx); + assert_eq!( + buffer.text(), + ["zero", "one", "two", "three ", "four", "five"].join("\n") + ); + }); +} + +#[gpui::test] +async fn test_trailing_whitespace_empty_ranges(cx: &mut gpui::TestAppContext) { + let text = ["zero", "one ", "two "].join("\n"); + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx)); + + let diff = buffer + .update(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(Some(&[]), cx) + }) + .await; + buffer.update(cx, |buffer, cx| { + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), text); + }); +} + +#[gpui::test] +async fn test_final_newline_modified_last_line(cx: &mut gpui::TestAppContext) { + // No final newline; the modified range (rows 0..3) includes the last line (row 2). + let text = "line0\nline1\nline2"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..3])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\nline2\n"); + }); +} + +#[gpui::test] +async fn test_final_newline_unmodified_last_line(cx: &mut gpui::TestAppContext) { + // No final newline; the modified range (rows 0..2) excludes the last line (row 2), so nothing changes. + let text = "line0\nline1\nline2"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..2])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\nline2"); + }); +} + +// An empty last line (file already ends with a newline) is left untouched, even with extra +// trailing blank lines. With `None` these would collapse; scoped to rows they must not, to +// avoid deleting unselected rows. +#[gpui::test] +async fn test_final_newline_does_not_collapse_trailing_blank_lines(cx: &mut gpui::TestAppContext) { + let text = "line0\nline1\n\n"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..4])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\n\n"); + }); +} + +// When scoped to rows, only a newline is inserted; unlike the `None` (whole-buffer) case, it +// does not trim trailing whitespace on the last line. +#[gpui::test] +async fn test_final_newline_in_range_only_inserts(cx: &mut gpui::TestAppContext) { + let text = "line0\nline1 "; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..2])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1 \n"); + }); +} + +#[gpui::test] +async fn test_final_newline_whole_buffer(cx: &mut gpui::TestAppContext) { + // (input, expected) pairs for the whole-buffer (`None`) case. + let cases = [ + // Content without a trailing newline gets exactly one appended. + ("line0\nline1", "line0\nline1\n"), + // A buffer already ending in a single newline is left untouched. + ("line0\nline1\n", "line0\nline1\n"), + // Trailing blank lines and whitespace at the end of the file collapse to one newline. + ("line0\nline1\n\n\n", "line0\nline1\n"), + ("line0\nline1 \n ", "line0\nline1\n"), + // An empty buffer stays empty. + ("", ""), + ]; + + for (input, expected) in cases { + let buffer = cx.new(|cx| Buffer::local(input, cx)); + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(None); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), expected, "wrong result for input {input:?}"); + }); + } +} + +#[gpui::test] +async fn test_trailing_whitespace_in_ranges_crlf(cx: &mut gpui::TestAppContext) { + let text = "zero\r\none \r\ntwo\r\nthree \r\nfour\r\nfive "; + let buffer = cx.new(|cx| { + let buffer = Buffer::local(text, cx); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + buffer + }); + + let modified_rows = [1u32..2, 5..6]; + let diff = buffer + .update(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(Some(&modified_rows), cx) + }) + .await; + buffer.update(cx, |buffer, cx| { + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "zero\none\ntwo\nthree \nfour\nfive"); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); +} + +#[gpui::test] +async fn test_final_newline_in_range_crlf(cx: &mut gpui::TestAppContext) { + let text = "line0\r\nline1\r\nline2"; + let buffer = cx.new(|cx| { + let buffer = Buffer::local(text, cx); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + buffer + }); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..3])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\nline2\n"); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); +} + #[gpui::test] fn test_words_in_range(cx: &mut gpui::App) { init_settings(cx, |_| {}); @@ -4152,6 +4504,90 @@ fn get_tree_sexp(buffer: &Entity, cx: &mut gpui::TestAppContext) -> Stri }) } +fn typescript_lang_with_indents() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "TypeScript".into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_brackets_query(r#"("{" @open "}" @close) ("(" @open ")" @close)"#) + .unwrap() + .with_indents_query(include_str!("../../grammars/src/typescript/indents.scm")) + .unwrap(), + ) +} + +fn tsx_lang_with_indents() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "TSX".into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_brackets_query(r#"("{" @open "}" @close) ("(" @open ")" @close)"#) + .unwrap() + .with_indents_query(include_str!("../../grammars/src/tsx/indents.scm")) + .unwrap(), + ) +} + +#[gpui::test] +fn test_autoindent_typescript_braceless_control_flow(cx: &mut App) { + init_settings(cx, |_| {}); + cx.new(|cx| { + for lang in [typescript_lang_with_indents(), tsx_lang_with_indents()] { + let mut indent = |header: &str, header_len: usize, body: &str| { + let mut buffer = Buffer::local(header, cx).with_language(lang.clone(), cx); + buffer.edit( + [(header_len..header_len, body)], + Some(AutoindentMode::EachLine), + cx, + ); + buffer.text() + }; + + // A braceless body is indented under its `if`/`for`/`while`. + assert_eq!(indent("if (true)", 9, "\nx()"), "if (true)\n x()"); + assert_eq!(indent("for (;;)", 8, "\nx()"), "for (;;)\n x()"); + assert_eq!(indent("while (true)", 12, "\nx()"), "while (true)\n x()"); + assert_eq!( + indent("for (const a of b)", 18, "\nx()"), + "for (const a of b)\n x()" + ); + + // The statement after a braceless body returns to the outer indent. + assert_eq!( + indent("if (true)\n x()", 17, "\ny()"), + "if (true)\n x()\ny()" + ); + + // A `{}` block keeps its brace unindented (Allman style), leaving the + // block rule to indent the contents. Regression guard for #24976. + assert_eq!(indent("if (true)", 9, "\n{}"), "if (true)\n{}"); + assert_eq!(indent("for (;;)", 8, "\n{}"), "for (;;)\n{}"); + assert_eq!(indent("while (true)", 12, "\n{}"), "while (true)\n{}"); + + // K&R braced bodies indent their contents once. + assert_eq!( + indent("if (true) {\n}", 11, "\nx()"), + "if (true) {\n x()\n}" + ); + + // A braceless `else` body is indented under the `else`. + assert_eq!( + indent("if (true)\n x()\nelse", 22, "\ny()"), + "if (true)\n x()\nelse\n y()" + ); + } + Buffer::local("", cx) + }); +} + // Assert that the enclosing bracket ranges around the selection match the pairs indicated by the marked text in `range_markers` #[track_caller] fn assert_bracket_pairs( diff --git a/crates/language/src/diagnostic.rs b/crates/language/src/diagnostic.rs index 951feec0da1858..9a468a14b863a9 100644 --- a/crates/language/src/diagnostic.rs +++ b/crates/language/src/diagnostic.rs @@ -1 +1,76 @@ -pub use language_core::diagnostic::{Diagnostic, DiagnosticSourceKind}; +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/src/language.rs b/crates/language/src/language.rs index 6a799b62d32206..9a031347ec0422 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -39,7 +39,10 @@ use futures::lock::OwnedMutexGuard; use gpui::{App, AsyncApp, Entity}; use http_client::HttpClient; -pub use language_core::highlight_map::{HighlightId, HighlightMap}; +pub use language_core::{ + SymbolKind, + highlight_map::{HighlightId, HighlightMap}, +}; use futures::future::FutureExt as _; pub use language_core::{ @@ -48,10 +51,10 @@ pub use language_core::{ 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, + OverrideEntry, RedactionConfig, RunnableCapture, RunnableConfig, SoftWrap, Symbol, + TaskListConfig, TextObject, TextObjectConfig, WrapCharactersConfig, default_true, + deserialize_regex, deserialize_regex_vec, regex_json_schema, regex_vec_json_schema, + serialize_regex, }; pub use language_registry::{ LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth, @@ -164,6 +167,7 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { LanguageConfig { name: "Plain Text".into(), soft_wrap: Some(SoftWrap::EditorWidth), + autoclose_before: ")]}".into(), matcher: LanguageMatcher { path_suffixes: vec!["txt".to_owned()], first_line_pattern: None, @@ -215,6 +219,69 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { )) }); +pub fn symbol_kind_to_lsp(kind: SymbolKind) -> lsp::SymbolKind { + match kind { + SymbolKind::File => lsp::SymbolKind::FILE, + SymbolKind::Module => lsp::SymbolKind::MODULE, + SymbolKind::Namespace => lsp::SymbolKind::NAMESPACE, + SymbolKind::Package => lsp::SymbolKind::PACKAGE, + SymbolKind::Class => lsp::SymbolKind::CLASS, + SymbolKind::Method => lsp::SymbolKind::METHOD, + SymbolKind::Property => lsp::SymbolKind::PROPERTY, + SymbolKind::Field => lsp::SymbolKind::FIELD, + SymbolKind::Constructor => lsp::SymbolKind::CONSTRUCTOR, + SymbolKind::Enum => lsp::SymbolKind::ENUM, + SymbolKind::Interface => lsp::SymbolKind::INTERFACE, + SymbolKind::Function => lsp::SymbolKind::FUNCTION, + SymbolKind::Variable => lsp::SymbolKind::VARIABLE, + SymbolKind::Constant => lsp::SymbolKind::CONSTANT, + SymbolKind::String => lsp::SymbolKind::STRING, + SymbolKind::Number => lsp::SymbolKind::NUMBER, + SymbolKind::Boolean => lsp::SymbolKind::BOOLEAN, + SymbolKind::Array => lsp::SymbolKind::ARRAY, + SymbolKind::Object => lsp::SymbolKind::OBJECT, + SymbolKind::Key => lsp::SymbolKind::KEY, + SymbolKind::Null => lsp::SymbolKind::NULL, + SymbolKind::EnumMember => lsp::SymbolKind::ENUM_MEMBER, + SymbolKind::Struct => lsp::SymbolKind::STRUCT, + SymbolKind::Event => lsp::SymbolKind::EVENT, + SymbolKind::Operator => lsp::SymbolKind::OPERATOR, + SymbolKind::TypeParameter => lsp::SymbolKind::TYPE_PARAMETER, + } +} + +pub fn lsp_to_symbol_kind(kind: lsp::SymbolKind) -> SymbolKind { + match kind { + lsp::SymbolKind::FILE => SymbolKind::File, + lsp::SymbolKind::MODULE => SymbolKind::Module, + lsp::SymbolKind::NAMESPACE => SymbolKind::Namespace, + lsp::SymbolKind::PACKAGE => SymbolKind::Package, + lsp::SymbolKind::CLASS => SymbolKind::Class, + lsp::SymbolKind::METHOD => SymbolKind::Method, + lsp::SymbolKind::PROPERTY => SymbolKind::Property, + lsp::SymbolKind::FIELD => SymbolKind::Field, + lsp::SymbolKind::CONSTRUCTOR => SymbolKind::Constructor, + lsp::SymbolKind::ENUM => SymbolKind::Enum, + lsp::SymbolKind::INTERFACE => SymbolKind::Interface, + lsp::SymbolKind::FUNCTION => SymbolKind::Function, + lsp::SymbolKind::VARIABLE => SymbolKind::Variable, + lsp::SymbolKind::CONSTANT => SymbolKind::Constant, + lsp::SymbolKind::STRING => SymbolKind::String, + lsp::SymbolKind::NUMBER => SymbolKind::Number, + lsp::SymbolKind::BOOLEAN => SymbolKind::Boolean, + lsp::SymbolKind::ARRAY => SymbolKind::Array, + lsp::SymbolKind::OBJECT => SymbolKind::Object, + lsp::SymbolKind::KEY => SymbolKind::Key, + lsp::SymbolKind::NULL => SymbolKind::Null, + lsp::SymbolKind::ENUM_MEMBER => SymbolKind::EnumMember, + lsp::SymbolKind::STRUCT => SymbolKind::Struct, + lsp::SymbolKind::EVENT => SymbolKind::Event, + lsp::SymbolKind::OPERATOR => SymbolKind::Operator, + lsp::SymbolKind::TYPE_PARAMETER => SymbolKind::TypeParameter, + _ => SymbolKind::Null, + } +} + /// Commands that the client (editor) handles locally rather than forwarding /// to the language server. Servers embed these in code lens and code action /// responses when they want the editor to perform a well-known UI action. @@ -232,6 +299,17 @@ pub struct Location { pub range: Range, } +/// 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, +} + type ServerBinaryCache = futures::lock::Mutex>; type DownloadableLanguageServerBinary = LocalBoxFuture<'static, Result>; pub type LanguageServerBinaryLocations = LocalBoxFuture< @@ -1238,9 +1316,9 @@ impl LanguageScope { pub fn language_allowed(&self, name: &LanguageServerName) -> bool { let config = &self.language.config; let opt_in_servers = &config.scope_opt_in_language_servers; - if opt_in_servers.contains(name) { + if opt_in_servers.contains(&name.0) { if let Some(over) = self.config_override() { - over.opt_into_language_servers.contains(name) + over.opt_into_language_servers.contains(&name.0) } else { false } diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index de67375ccccf71..1cf70338f46b1a 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -19,7 +19,8 @@ pub use settings::{ AutoIndentMode, CompletionSettingsContent, EditPredictionDataCollectionChoice, EditPredictionPromptFormatContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave, Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LineEndingSetting, - LspInsertMode, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode, + LspInsertMode, REST_OF_LANGUAGE_SERVERS, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, + WordsCompletionMode, }; use settings::{RegisterSetting, Settings, SettingsLocation, SettingsStore, merge_from::MergeFrom}; use shellexpand; @@ -276,9 +277,6 @@ pub struct PrettierSettings { } 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) } @@ -382,7 +380,7 @@ impl LanguageSettings { enabled_language_servers .into_iter() .flat_map(|language_server| { - if language_server.0.as_ref() == Self::REST_OF_LANGUAGE_SERVERS { + if language_server.0.as_ref() == REST_OF_LANGUAGE_SERVERS { rest.clone() } else { vec![language_server] @@ -1090,7 +1088,7 @@ mod tests { // A value of just `["..."]` is the same as taking all of the available language servers. assert_eq!( LanguageSettings::resolve_language_servers( - &[LanguageSettings::REST_OF_LANGUAGE_SERVERS.into()], + &[REST_OF_LANGUAGE_SERVERS.into()], &available_language_servers, ), available_language_servers @@ -1101,7 +1099,7 @@ mod tests { LanguageSettings::resolve_language_servers( &[ "biome".into(), - LanguageSettings::REST_OF_LANGUAGE_SERVERS.into(), + REST_OF_LANGUAGE_SERVERS.into(), "deno".into() ], &available_language_servers @@ -1122,7 +1120,7 @@ mod tests { "deno".into(), "!typescript-language-server".into(), "!biome".into(), - LanguageSettings::REST_OF_LANGUAGE_SERVERS.into() + REST_OF_LANGUAGE_SERVERS.into() ], &available_language_servers ), @@ -1134,7 +1132,7 @@ mod tests { LanguageSettings::resolve_language_servers( &[ "my-cool-language-server".into(), - LanguageSettings::REST_OF_LANGUAGE_SERVERS.into() + REST_OF_LANGUAGE_SERVERS.into() ], &available_language_servers ), diff --git a/crates/language/src/text_diff.rs b/crates/language/src/text_diff.rs index 69d241cabdd791..4e8239672b2db0 100644 --- a/crates/language/src/text_diff.rs +++ b/crates/language/src/text_diff.rs @@ -1,10 +1,6 @@ use crate::{CharClassifier, CharKind, CharScopeContext, LanguageScope}; use anyhow::{Context, anyhow}; -use imara_diff::{ - Algorithm, Sink, diff, - intern::{InternedInput, Interner, Token}, - sources::lines_with_terminator, -}; +use imara_diff::{Algorithm, Diff, InternedInput, Interner, Token, sources::lines}; use std::{fmt::Write, iter, ops::Range, sync::Arc}; const MAX_WORD_DIFF_LEN: usize = 512; @@ -36,12 +32,17 @@ pub fn unified_diff_with_context( new_start_line: u32, context_lines: u32, ) -> String { - let input = InternedInput::new(old_text, new_text); - diff( - Algorithm::Histogram, - &input, - OffsetUnifiedDiffBuilder::new(&input, old_start_line, new_start_line, context_lines), - ) + // The builder appends its own line terminators, so tokenize without them. + let mut input = InternedInput::default(); + input.update_before(old_text.lines()); + input.update_after(new_text.lines()); + let diff = Diff::compute(Algorithm::Histogram, &input); + let mut builder = + OffsetUnifiedDiffBuilder::new(&input, old_start_line, new_start_line, context_lines); + for hunk in diff.hunks() { + builder.process_change(hunk.before, hunk.after); + } + builder.finish() } /// A unified diff builder that applies line number offsets to hunk headers. @@ -126,9 +127,7 @@ impl<'a> OffsetUnifiedDiffBuilder<'a> { } } -impl Sink for OffsetUnifiedDiffBuilder<'_> { - type Out = String; - +impl OffsetUnifiedDiffBuilder<'_> { fn process_change(&mut self, before: Range, after: Range) { if before.start - self.pos > self.context_lines * 2 { self.flush(); @@ -148,7 +147,7 @@ impl Sink for OffsetUnifiedDiffBuilder<'_> { self.print_tokens(&self.after[after.start as usize..after.end as usize], '+'); } - fn finish(mut self) -> Self::Out { + fn finish(mut self) -> String { self.flush(); self.dst } @@ -158,10 +157,7 @@ impl Sink for OffsetUnifiedDiffBuilder<'_> { /// ranges. pub fn line_diff(old_text: &str, new_text: &str) -> Vec<(Range, Range)> { let mut edits = Vec::new(); - let input = InternedInput::new( - lines_with_terminator(old_text), - lines_with_terminator(new_text), - ); + let input = InternedInput::new(lines(old_text), lines(new_text)); diff_internal(&input, &mut |_, _, old_rows, new_rows| { edits.push((old_rows, new_rows)); }); @@ -264,10 +260,7 @@ pub fn text_diff_with_options( let empty: Arc = Arc::default(); let mut edits = Vec::new(); let mut hunk_input = InternedInput::default(); - let input = InternedInput::new( - lines_with_terminator(old_text), - lines_with_terminator(new_text), - ); + let input = InternedInput::new(lines(old_text), lines(new_text)); diff_internal(&input, &mut |old_byte_range, new_byte_range, old_rows, @@ -349,35 +342,34 @@ fn diff_internal( let mut new_offset = 0; let mut old_token_ix = 0; let mut new_token_ix = 0; - diff( - Algorithm::Histogram, - input, - |old_tokens: Range, new_tokens: Range| { - old_offset += token_len( - input, - &input.before[old_token_ix as usize..old_tokens.start as usize], - ); - new_offset += token_len( - input, - &input.after[new_token_ix as usize..new_tokens.start as usize], - ); - let old_len = token_len( - input, - &input.before[old_tokens.start as usize..old_tokens.end as usize], - ); - let new_len = token_len( - input, - &input.after[new_tokens.start as usize..new_tokens.end as usize], - ); - let old_byte_range = old_offset..old_offset + old_len; - let new_byte_range = new_offset..new_offset + new_len; - old_token_ix = old_tokens.end; - new_token_ix = new_tokens.end; - old_offset = old_byte_range.end; - new_offset = new_byte_range.end; - on_change(old_byte_range, new_byte_range, old_tokens, new_tokens); - }, - ); + let diff = Diff::compute(Algorithm::Histogram, input); + for hunk in diff.hunks() { + let old_tokens = hunk.before; + let new_tokens = hunk.after; + old_offset += token_len( + input, + &input.before[old_token_ix as usize..old_tokens.start as usize], + ); + new_offset += token_len( + input, + &input.after[new_token_ix as usize..new_tokens.start as usize], + ); + let old_len = token_len( + input, + &input.before[old_tokens.start as usize..old_tokens.end as usize], + ); + let new_len = token_len( + input, + &input.after[new_tokens.start as usize..new_tokens.end as usize], + ); + let old_byte_range = old_offset..old_offset + old_len; + let new_byte_range = new_offset..new_offset + new_len; + old_token_ix = old_tokens.end; + new_token_ix = new_tokens.end; + old_offset = old_byte_range.end; + new_offset = new_byte_range.end; + on_change(old_byte_range, new_byte_range, old_tokens, new_tokens); + } } fn tokenize_chars(text: &str) -> impl Iterator { diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml index cd1143f61d3af1..7d77847a9c65a6 100644 --- a/crates/language_core/Cargo.toml +++ b/crates/language_core/Cargo.toml @@ -11,17 +11,16 @@ path = "src/language_core.rs" anyhow.workspace = true collections.workspace = true gpui_shared_string.workspace = true +gpui_util.workspace = true log.workspace = true -lsp.workspace = true parking_lot.workspace = true +path.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 - [features] test-support = [] diff --git a/crates/language_core/src/code_label.rs b/crates/language_core/src/code_label.rs index 0a98743d02b386..d171527478bdae 100644 --- a/crates/language_core/src/code_label.rs +++ b/crates/language_core/src/code_label.rs @@ -1,10 +1,74 @@ use crate::highlight_map::HighlightId; use std::ops::Range; +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +pub enum SymbolKind { + File, + Module, + Namespace, + Package, + Class, + Method, + Property, + Field, + Constructor, + Enum, + Interface, + Function, + Variable, + Constant, + String, + Number, + Boolean, + Array, + Object, + Key, + Null, + EnumMember, + Struct, + Event, + Operator, + TypeParameter, +} + +impl SymbolKind { + pub fn from_proto(i32: i32) -> Self { + match i32 { + 1 => SymbolKind::File, + 2 => SymbolKind::Module, + 3 => SymbolKind::Namespace, + 4 => SymbolKind::Package, + 5 => SymbolKind::Class, + 6 => SymbolKind::Method, + 7 => SymbolKind::Property, + 8 => SymbolKind::Field, + 9 => SymbolKind::Constructor, + 10 => SymbolKind::Enum, + 11 => SymbolKind::Interface, + 12 => SymbolKind::Function, + 13 => SymbolKind::Variable, + 14 => SymbolKind::Constant, + 15 => SymbolKind::String, + 16 => SymbolKind::Number, + 17 => SymbolKind::Boolean, + 18 => SymbolKind::Array, + 19 => SymbolKind::Object, + 20 => SymbolKind::Key, + 21 => SymbolKind::Null, + 22 => SymbolKind::EnumMember, + 23 => SymbolKind::Struct, + 24 => SymbolKind::Event, + 25 => SymbolKind::Operator, + 26 => SymbolKind::TypeParameter, + _ => SymbolKind::Null, + } + } +} + #[derive(Debug, Clone)] pub struct Symbol { pub name: String, - pub kind: lsp::SymbolKind, + pub kind: SymbolKind, pub container_name: Option, } diff --git a/crates/language_core/src/diagnostic.rs b/crates/language_core/src/diagnostic.rs deleted file mode 100644 index 00abcb61d1b129..00000000000000 --- a/crates/language_core/src/diagnostic.rs +++ /dev/null @@ -1,76 +0,0 @@ -use gpui_shared_string::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 index d81ced4420ae2b..7ab210b56cdf3f 100644 --- a/crates/language_core/src/grammar.rs +++ b/crates/language_core/src/grammar.rs @@ -5,7 +5,6 @@ use crate::{ use anyhow::{Context as _, Result}; use collections::HashMap; use gpui_shared_string::SharedString; -use lsp::LanguageServerName; use parking_lot::Mutex; use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; use tree_sitter::Query; @@ -673,7 +672,7 @@ impl Grammar { language_name: &LanguageName, overrides: &HashMap, brackets: &mut BracketPairConfig, - scope_opt_in_language_servers: &[LanguageServerName], + scope_opt_in_language_servers: &[SharedString], ) -> Result { let query = Query::new(&self.ts_language, source)?; @@ -691,7 +690,7 @@ impl Grammar { 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!( + gpui_util::debug_panic!( "Server {server_name:?} has been opted-in by scope {name:?} but has not been marked as an opt-in server" ); } diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs index 93f9423ada0642..5eea2d123b1585 100644 --- a/crates/language_core/src/language_config.rs +++ b/crates/language_core/src/language_config.rs @@ -1,12 +1,10 @@ use crate::LanguageName; use collections::{HashMap, HashSet, IndexSet}; use gpui_shared_string::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)] @@ -47,7 +45,7 @@ pub struct LanguageConfig { 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")] + #[serde(default = "default_true")] pub auto_indent_using_last_non_empty_line: bool, // Whether indentation of pasted content should be adjusted based on the context. #[serde(default)] @@ -105,7 +103,7 @@ pub struct LanguageConfig { 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, + 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 @@ -167,7 +165,7 @@ impl Default for LanguageConfig { 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_using_last_non_empty_line: default_true(), auto_indent_on_paste: None, increase_indent_pattern: Default::default(), decrease_indent_pattern: Default::default(), @@ -377,7 +375,7 @@ pub struct LanguageConfigOverride { #[serde(default)] pub linked_edit_characters: Override>, #[serde(default)] - pub opt_into_language_servers: Vec, + pub opt_into_language_servers: Vec, #[serde(default)] pub prefer_label_for_snippet: Option, } @@ -481,7 +479,7 @@ pub struct WrapCharactersConfig { pub end_suffix: String, } -pub fn auto_indent_using_last_non_empty_line_default() -> bool { +pub fn default_true() -> bool { true } diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs index f3292e1978d976..2edbcbea9d280d 100644 --- a/crates/language_core/src/language_core.rs +++ b/crates/language_core/src/language_core.rs @@ -1,12 +1,10 @@ // 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, @@ -17,9 +15,9 @@ 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, + OrderedListConfig, Override, SoftWrap, TaskListConfig, WrapCharactersConfig, default_true, + deserialize_regex, deserialize_regex_vec, regex_json_schema, regex_vec_json_schema, + serialize_regex, }; pub mod code_label; @@ -29,11 +27,9 @@ pub mod manifest; pub mod queries; pub mod toolchain; -pub use code_label::{CodeLabel, CodeLabelBuilder, Symbol}; +pub use code_label::{CodeLabel, CodeLabelBuilder, Symbol, SymbolKind}; pub use language_name::{LanguageId, LanguageName}; -pub use lsp_adapter::{ - BinaryStatus, LanguageServerStatusUpdate, PromptResponseContext, ServerHealth, ToLspPosition, -}; +pub use lsp_adapter::{BinaryStatus, LanguageServerStatusUpdate, ServerHealth}; 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/lsp_adapter.rs b/crates/language_core/src/lsp_adapter.rs index 8f449637b306c2..34fac0790b31cc 100644 --- a/crates/language_core/src/lsp_adapter.rs +++ b/crates/language_core/src/lsp_adapter.rs @@ -1,23 +1,6 @@ use gpui_shared_string::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), diff --git a/crates/language_core/src/toolchain.rs b/crates/language_core/src/toolchain.rs index 78bd69917fbc0f..f5b09190975d87 100644 --- a/crates/language_core/src/toolchain.rs +++ b/crates/language_core/src/toolchain.rs @@ -7,7 +7,7 @@ use std::{path::Path, sync::Arc}; use gpui_shared_string::SharedString; -use util::rel_path::RelPath; +use path::rel_path::RelPath; use crate::{LanguageName, ManifestName}; diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 3c28e07e6b306e..d9f28100d809d8 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -79,16 +79,19 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy { let mut tasks = Vec::new(); match &self.lsp_access { - LspAccess::ViaLspStore(lsp_store) => lsp_store.update(cx, |lsp_store, cx| { - let stop_task = lsp_store.stop_language_servers_for_buffers( - Vec::new(), - HashSet::from_iter([LanguageServerSelector::Name( - language_server_name.clone(), - )]), - cx, - ); - tasks.push(stop_task); - }), + LspAccess::ViaLspStore(lsp_store) => { + if let Ok(stop_task) = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.stop_language_servers_for_buffers( + Vec::new(), + HashSet::from_iter([LanguageServerSelector::Name( + language_server_name.clone(), + )]), + cx, + ) + }) { + tasks.push(stop_task); + } + } LspAccess::ViaWorkspaces(lsp_store_provider) => { if let Ok(lsp_stores) = lsp_store_provider(cx) { for lsp_store in lsp_stores { @@ -473,7 +476,7 @@ impl LspAdapter for ExtensionLspAdapter { container_name, }| extension::Symbol { name, - kind: lsp_symbol_kind_to_extension(kind), + kind: symbol_kind_to_extension(kind), container_name, }, ) @@ -633,35 +636,34 @@ fn lsp_insert_text_format_to_extension( } } -fn lsp_symbol_kind_to_extension(value: lsp::SymbolKind) -> extension::SymbolKind { +fn symbol_kind_to_extension(value: language::SymbolKind) -> extension::SymbolKind { match value { - lsp::SymbolKind::FILE => extension::SymbolKind::File, - lsp::SymbolKind::MODULE => extension::SymbolKind::Module, - lsp::SymbolKind::NAMESPACE => extension::SymbolKind::Namespace, - lsp::SymbolKind::PACKAGE => extension::SymbolKind::Package, - lsp::SymbolKind::CLASS => extension::SymbolKind::Class, - lsp::SymbolKind::METHOD => extension::SymbolKind::Method, - lsp::SymbolKind::PROPERTY => extension::SymbolKind::Property, - lsp::SymbolKind::FIELD => extension::SymbolKind::Field, - lsp::SymbolKind::CONSTRUCTOR => extension::SymbolKind::Constructor, - lsp::SymbolKind::ENUM => extension::SymbolKind::Enum, - lsp::SymbolKind::INTERFACE => extension::SymbolKind::Interface, - lsp::SymbolKind::FUNCTION => extension::SymbolKind::Function, - lsp::SymbolKind::VARIABLE => extension::SymbolKind::Variable, - lsp::SymbolKind::CONSTANT => extension::SymbolKind::Constant, - lsp::SymbolKind::STRING => extension::SymbolKind::String, - lsp::SymbolKind::NUMBER => extension::SymbolKind::Number, - lsp::SymbolKind::BOOLEAN => extension::SymbolKind::Boolean, - lsp::SymbolKind::ARRAY => extension::SymbolKind::Array, - lsp::SymbolKind::OBJECT => extension::SymbolKind::Object, - lsp::SymbolKind::KEY => extension::SymbolKind::Key, - lsp::SymbolKind::NULL => extension::SymbolKind::Null, - lsp::SymbolKind::ENUM_MEMBER => extension::SymbolKind::EnumMember, - lsp::SymbolKind::STRUCT => extension::SymbolKind::Struct, - lsp::SymbolKind::EVENT => extension::SymbolKind::Event, - lsp::SymbolKind::OPERATOR => extension::SymbolKind::Operator, - lsp::SymbolKind::TYPE_PARAMETER => extension::SymbolKind::TypeParameter, - _ => extension::SymbolKind::Other(extract_int(value)), + language::SymbolKind::File => extension::SymbolKind::File, + language::SymbolKind::Module => extension::SymbolKind::Module, + language::SymbolKind::Namespace => extension::SymbolKind::Namespace, + language::SymbolKind::Package => extension::SymbolKind::Package, + language::SymbolKind::Class => extension::SymbolKind::Class, + language::SymbolKind::Method => extension::SymbolKind::Method, + language::SymbolKind::Property => extension::SymbolKind::Property, + language::SymbolKind::Field => extension::SymbolKind::Field, + language::SymbolKind::Constructor => extension::SymbolKind::Constructor, + language::SymbolKind::Enum => extension::SymbolKind::Enum, + language::SymbolKind::Interface => extension::SymbolKind::Interface, + language::SymbolKind::Function => extension::SymbolKind::Function, + language::SymbolKind::Variable => extension::SymbolKind::Variable, + language::SymbolKind::Constant => extension::SymbolKind::Constant, + language::SymbolKind::String => extension::SymbolKind::String, + language::SymbolKind::Number => extension::SymbolKind::Number, + language::SymbolKind::Boolean => extension::SymbolKind::Boolean, + language::SymbolKind::Array => extension::SymbolKind::Array, + language::SymbolKind::Object => extension::SymbolKind::Object, + language::SymbolKind::Key => extension::SymbolKind::Key, + language::SymbolKind::Null => extension::SymbolKind::Null, + language::SymbolKind::EnumMember => extension::SymbolKind::EnumMember, + language::SymbolKind::Struct => extension::SymbolKind::Struct, + language::SymbolKind::Event => extension::SymbolKind::Event, + language::SymbolKind::Operator => extension::SymbolKind::Operator, + language::SymbolKind::TypeParameter => extension::SymbolKind::TypeParameter, } } diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs index 96536b6c021c6c..ffddd3d90f89ee 100644 --- a/crates/language_extension/src/language_extension.rs +++ b/crates/language_extension/src/language_extension.rs @@ -5,13 +5,13 @@ use std::sync::Arc; use anyhow::Result; use extension::{ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy}; -use gpui::{App, Entity}; +use gpui::{App, Entity, WeakEntity}; use language::{LanguageMatcher, LanguageName, LanguageRegistry, LoadedLanguage}; use project::LspStore; #[derive(Clone)] pub enum LspAccess { - ViaLspStore(Entity), + ViaLspStore(WeakEntity), ViaWorkspaces(Arc Result>> + Send + Sync + 'static>), Noop, } diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index d679588138ccec..2f0a6911f4e4a3 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -17,12 +17,13 @@ test-support = [] [dependencies] anyhow.workspace = true -credentials_provider.workspace = true base64.workspace = true collections.workspace = true +credentials_provider.workspace = true env_var.workspace = true futures.workspace = true gpui.workspace = true +gpui_util.workspace = true http_client.workspace = true icons.workspace = true image.workspace = true @@ -32,7 +33,6 @@ parking_lot.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true -util.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_model/src/api_key.rs b/crates/language_model/src/api_key.rs index 4be5a64d3db623..e2645d37f0ddc7 100644 --- a/crates/language_model/src/api_key.rs +++ b/crates/language_model/src/api_key.rs @@ -3,11 +3,11 @@ use credentials_provider::CredentialsProvider; use env_var::EnvVar; use futures::{FutureExt, future}; use gpui::{AsyncApp, Context, SharedString, Task}; +use gpui_util::ResultExt as _; use std::{ fmt::{Display, Formatter}, sync::Arc, }; -use util::ResultExt as _; use crate::AuthenticateError; diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index f58130cccf44f6..a9cb4c5b5fb5c8 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -1,12 +1,12 @@ use crate::{ - AuthenticateError, ConfigurationViewTargetAgent, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, + AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, }; use anyhow::anyhow; use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream, stream::StreamExt}; -use gpui::{AnyView, App, AsyncApp, Entity, Task, Window}; +use gpui::{App, AsyncApp, Entity, Task}; use http_client::Result; use parking_lot::Mutex; use std::sync::{ @@ -68,17 +68,8 @@ impl LanguageModelProvider for FakeLanguageModelProvider { Task::ready(Ok(())) } - fn configuration_view( - &self, - _target_agent: ConfigurationViewTargetAgent, - _window: &mut Window, - _: &mut App, - ) -> AnyView { - unimplemented!() - } - - fn reset_credentials(&self, _: &mut App) -> Task> { - Task::ready(Ok(())) + fn settings_view(&self, _: &mut App) -> Option { + None } } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index df911d1b77ba6b..2e060049d0b056 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -15,6 +15,8 @@ use icons::IconName; use parking_lot::Mutex; use std::sync::Arc; +pub type CreateProviderSettingsView = Arc AnyView + 'static>; + pub use crate::api_key::{ApiKey, ApiKeyState}; pub use crate::registry::*; pub use crate::request::{LanguageModelImageExt, gpui_size_to_image_size, image_size_to_gpui}; @@ -320,13 +322,11 @@ pub trait LanguageModelProvider: 'static { } fn is_authenticated(&self, cx: &App) -> bool; fn authenticate(&self, cx: &mut App) -> Task>; - fn configuration_view( - &self, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView; - fn reset_credentials(&self, cx: &mut App) -> Task>; + fn settings_view(&self, cx: &mut App) -> Option; + + fn set_api_key(&self, _key: Option, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } /// Copy shown when this provider rejects a request as unauthenticated /// (HTTP 401). The default assumes API-key authentication; providers using @@ -335,7 +335,7 @@ pub trait LanguageModelProvider: 'static { fn authentication_error_message(&self) -> SharedString { format!( "The API key for {} is invalid or has expired. \ - Update your key via the Agent Panel settings to continue.", + Update your key in Settings > AI > LLM Providers to continue.", self.name().0 ) .into() @@ -348,29 +348,12 @@ pub trait LanguageModelProvider: 'static { fn missing_credentials_error_message(&self) -> SharedString { format!( "No API key is configured for {}. \ - Add your key via the Agent Panel settings to continue.", + Add your key in Settings > AI > LLM Providers to continue.", self.name().0 ) .into() } - /// Returns the provider's configuration UI together with how it prefers to - /// be presented: [`ProviderConfigurationView::Inline`] for a compact control - /// that can sit in a list row (e.g. a single API-key field), or - /// [`ProviderConfigurationView::SubPage`] for a richer view that needs its - /// own surface. - /// - /// The default reuses [`Self::configuration_view`] as a sub-page, so - /// providers only override this when they have a compact inline form. - fn configuration_view_v2( - &self, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - ProviderConfigurationView::SubPage(self.configuration_view(target_agent, window, cx)) - } - /// Copy shown the first time a user enables fast mode for a model from /// this provider. Returning `None` skips the confirmation prompt and lets /// the toggle apply silently. @@ -379,14 +362,76 @@ pub trait LanguageModelProvider: 'static { } } -/// How a provider's configuration UI prefers to be presented by the settings UI. +/// A provider's settings UI, modeled as mutually exclusive presentation modes. #[derive(Clone)] -pub enum ProviderConfigurationView { - /// A compact control suitable for rendering inline in a list row, such as a - /// single API-key field. - Inline(AnyView), - /// A richer view that should be shown on its own dedicated sub-page. - SubPage(AnyView), +pub enum ProviderSettingsView { + ApiKey(ApiKeyConfiguration), + Inline(InlineProviderSettings), + SubPage(SubPageProviderSettings), +} + +#[derive(Clone)] +pub struct InlineProviderSettings { + pub title: Option, + pub description: Option, + pub create_view: CreateProviderSettingsView, +} + +#[derive(Clone)] +pub struct SubPageProviderSettings { + pub description: Option, + pub create_view: CreateProviderSettingsView, +} + +impl SubPageProviderSettings { + pub fn new(create_view: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { + Self { + description: None, + create_view: Arc::new(create_view), + } + } + + pub fn description(mut self, description: InlineDescription) -> Self { + self.description = Some(description); + self + } +} + +impl ApiKeyConfiguration { + pub fn new( + has_key: bool, + is_from_env_var: bool, + env_var_name: SharedString, + api_key_url: SharedString, + ) -> Self { + Self { + has_key, + is_from_env_var, + env_var_name, + api_key_url, + } + } +} + +/// A live snapshot of a single-API-key provider's credential state, used by the +/// settings UI to render the provider's "API Key" section. +#[derive(Clone)] +pub struct ApiKeyConfiguration { + pub has_key: bool, + pub is_from_env_var: bool, + pub env_var_name: SharedString, + pub api_key_url: SharedString, +} + +/// The subtitle rendered beneath a provider's name when its configuration is +/// shown inline. +#[derive(Clone)] +pub enum InlineDescription { + /// A clickable "Where to find key" link pointing at the given URL, for + /// API-key based providers. + ApiKeyUrl(SharedString), + /// Plain descriptive text, e.g. explaining a sign-in based provider. + Text(SharedString), } /// Provider-specific copy shown the first time a user enables fast mode. @@ -396,13 +441,6 @@ pub struct FastModeConfirmation { pub message: SharedString, } -#[derive(Default, Clone, PartialEq, Eq)] -pub enum ConfigurationViewTargetAgent { - #[default] - ZedAgent, - Other(SharedString), -} - pub trait LanguageModelProviderState: 'static { type ObservableEntity; diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index ddc30b5d30ac86..28033e482f362b 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -44,6 +44,8 @@ impl std::fmt::Debug for ConfigurationError { #[derive(Default)] pub struct LanguageModelRegistry { + /// True if the user has *NO* default model configured in settings + should_use_fallback: bool, default_model: Option, /// This model is automatically configured by a user's environment after /// authenticating all providers. It's only used when `default_model` is not set. @@ -151,6 +153,10 @@ impl LanguageModelRegistry { self.default_model.as_ref().unwrap().model.clone() } + pub fn set_should_use_fallback(&mut self, value: bool) { + self.should_use_fallback = value; + } + pub fn register_provider( &mut self, provider: Arc, @@ -357,11 +363,30 @@ impl LanguageModelRegistry { self.default_model = model; } - pub fn set_environment_fallback_model( - &mut self, - model: Option, - cx: &mut Context, - ) { + pub fn refresh_fallback_model(&mut self, cx: &mut Context) { + // If the fallback model was already set or we don't want to use it, do nothing + if !self.should_use_fallback || self.available_fallback_model.is_some() { + return; + } + + let fallback_model = self + .providers() + .iter() + .filter(|provider| provider.is_authenticated(cx)) + .find_map(|provider| { + let model = provider + .default_model(cx) + .or_else(|| provider.recommended_models(cx).first().cloned())?; + Some(ConfiguredModel { + provider: provider.clone(), + model, + }) + }); + + self.set_fallback_model(fallback_model, cx); + } + + fn set_fallback_model(&mut self, model: Option, cx: &mut Context) { if self.default_model.is_none() { match (self.available_fallback_model.as_ref(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} @@ -417,9 +442,13 @@ impl LanguageModelRegistry { return None; } - self.default_model - .clone() - .or_else(|| self.available_fallback_model.clone()) + self.default_model.clone().or_else(|| { + if self.should_use_fallback { + self.available_fallback_model.clone() + } else { + None + } + }) } pub fn default_fast_model(&self, cx: &App) -> Option { @@ -617,7 +646,7 @@ mod tests { } #[gpui::test] - async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) { + async fn test_configure_fallback_model(cx: &mut gpui::TestAppContext) { let registry = cx.new(|_| LanguageModelRegistry::default()); let provider = Arc::new(FakeLanguageModelProvider::default()); @@ -631,7 +660,7 @@ mod tests { let provider = registry.provider(&provider.id()).unwrap(); let model = provider.default_model(cx).unwrap(); - registry.set_environment_fallback_model( + registry.set_fallback_model( Some(ConfiguredModel { provider: provider.clone(), model: model.clone(), @@ -639,6 +668,10 @@ mod tests { cx, ); + assert!(registry.default_model().is_none()); + + registry.set_should_use_fallback(true); + let default_model = registry.default_model().unwrap(); assert_eq!(default_model.model.id(), model.id()); assert_eq!(default_model.provider.id(), provider.id()); diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index b4dedbbd7ba447..fdea3d7dc76ed4 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -6,9 +6,9 @@ use base64::{Engine as _, write::EncoderWriter}; use gpui::{ App, AppContext as _, DevicePixels, Image, ImageFormat, ObjectFit, Size, Task, point, px, size, }; +use gpui_util::ResultExt; use image::GenericImageView as _; use image::codecs::png::PngEncoder; -use util::ResultExt; use language_model_core::{ImageSize, LanguageModelImage}; diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml index c254989b4d5736..f4a59a3e08dc30 100644 --- a/crates/language_model_core/Cargo.toml +++ b/crates/language_model_core/Cargo.toml @@ -26,3 +26,6 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 26ef0867db50bf..e514a0155a80f0 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -5,7 +5,7 @@ mod role; pub mod tool_schema; pub mod util; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; use cloud_llm_client::CompletionRequestStatus; use http_client::{StatusCode, http}; use schemars::JsonSchema; @@ -25,7 +25,10 @@ pub use crate::rate_limiter::*; pub use crate::request::*; pub use crate::role::*; pub use crate::tool_schema::LanguageModelToolSchemaFormat; -pub use crate::util::{fix_streamed_json, parse_prompt_too_long, parse_tool_arguments}; +pub use crate::util::{ + fix_streamed_json, is_context_window_exceeded_message, parse_prompt_too_long, + parse_tool_arguments, +}; pub use gpui_shared_string::SharedString; /// A completion event from a language model. @@ -241,7 +244,13 @@ impl LanguageModelCompletionError { retry_after: Option, ) -> Self { match status_code { - StatusCode::BAD_REQUEST => Self::BadRequestFormat { provider, message }, + StatusCode::BAD_REQUEST => { + if is_context_window_exceeded_message(&message) { + Self::PromptTooLarge { tokens: None } + } else { + Self::BadRequestFormat { provider, message } + } + } StatusCode::UNAUTHORIZED => Self::AuthenticationError { provider, message }, StatusCode::FORBIDDEN => Self::PermissionError { provider, message }, StatusCode::NOT_FOUND => Self::ApiEndpointNotFound { provider }, @@ -351,13 +360,101 @@ pub struct LanguageModelToolUse { pub id: LanguageModelToolUseId, pub name: Arc, pub raw_input: String, - pub input: serde_json::Value, + pub input: LanguageModelToolUseInput, pub is_input_complete: bool, /// Thought signature the model sent us. Some models require that this /// signature be preserved and sent back in conversation history for validation. pub thought_signature: Option, } +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub enum LanguageModelToolUseInput { + Json(serde_json::Value), + Text(String), +} + +impl Serialize for LanguageModelToolUseInput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("LanguageModelToolUseInput", 2)?; + match self { + Self::Json(input) => { + state.serialize_field("type", "json")?; + state.serialize_field("value", input)?; + } + Self::Text(input) => { + state.serialize_field("type", "text")?; + state.serialize_field("value", input)?; + } + } + state.end() + } +} + +impl<'de> Deserialize<'de> for LanguageModelToolUseInput { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + if let Some(object) = value.as_object() + && object.len() == 2 + && let Some(input_type) = object.get("type").and_then(|value| value.as_str()) + && let Some(input) = object.get("value") + { + return match input_type { + "json" => Ok(Self::Json(input.clone())), + "text" => input + .as_str() + .map(|input| Self::Text(input.to_string())) + .ok_or_else(|| serde::de::Error::custom("text tool input must be a string")), + _ => Ok(Self::Json(value)), + }; + } + + Ok(Self::Json(value)) + } +} + +impl LanguageModelToolUseInput { + pub fn as_json(&self) -> Option<&serde_json::Value> { + match self { + Self::Json(input) => Some(input), + Self::Text(_) => None, + } + } + + /// Typed parsing for JSON tool inputs; freeform (Text) inputs always error. + /// + /// Callers wanting the raw value should use [`Self::as_json`] or [`Self::into_json`]. + pub fn parse(&self) -> Result { + match self { + Self::Json(input) => { + serde_json::from_value(input.clone()).context("failed to parse JSON tool input") + } + Self::Text(_) => Err(anyhow!("custom tool text input cannot be parsed as JSON")), + } + } + + pub fn into_json(self) -> Result { + match self { + Self::Json(input) => Ok(input), + Self::Text(_) => Err(anyhow!("custom tool text input cannot be used as JSON")), + } + } + + pub fn to_display_json(&self) -> serde_json::Value { + match self { + Self::Json(input) => input.clone(), + Self::Text(input) => serde_json::Value::String(input.clone()), + } + } +} + #[derive(Debug, Clone)] pub struct LanguageModelEffortLevel { pub name: SharedString, @@ -460,6 +557,7 @@ pub enum ModelMode { Thinking { budget_tokens: Option, }, + Adaptive, } /// Settings-layer–free reasoning-effort enum. @@ -558,6 +656,33 @@ mod tests { } } + #[test] + fn test_from_http_status_maps_context_length_exceeded_to_prompt_too_large() { + let error = LanguageModelCompletionError::from_http_status( + String::from("OpenAI").into(), + StatusCode::BAD_REQUEST, + r#"{"error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"Your input exceeds the context window of this model. Please adjust your input and try again.","param":"input"}}"#.to_string(), + None, + ); + + assert!(matches!( + error, + LanguageModelCompletionError::PromptTooLarge { tokens: None } + )); + + let error = LanguageModelCompletionError::from_http_status( + String::from("OpenAI").into(), + StatusCode::BAD_REQUEST, + "Invalid request.".to_string(), + None, + ); + + assert!(matches!( + error, + LanguageModelCompletionError::BadRequestFormat { .. } + )); + } + #[test] fn test_from_cloud_failure_with_standard_format() { let error = LanguageModelCompletionError::from_cloud_failure( @@ -624,7 +749,7 @@ mod tests { id: LanguageModelToolUseId::from("test_id"), name: "test_tool".into(), raw_input: json!({"arg": "value"}).to_string(), - input: json!({"arg": "value"}), + input: LanguageModelToolUseInput::Json(json!({"arg": "value"})), is_input_complete: true, thought_signature: Some("test_signature".to_string()), }; @@ -652,9 +777,97 @@ mod tests { assert_eq!(tool_use.id, LanguageModelToolUseId::from("test_id")); assert_eq!(tool_use.name.as_ref(), "test_tool"); + assert_eq!( + tool_use.input, + LanguageModelToolUseInput::Json(json!({"arg": "value"})) + ); assert_eq!(tool_use.thought_signature, None); } + #[test] + fn test_language_model_tool_use_input_round_trips_json() { + use serde_json::json; + + let input = LanguageModelToolUseInput::Json(json!({"arg": "value"})); + let serialized = serde_json::to_value(&input).unwrap(); + assert_eq!( + serialized, + json!({ + "type": "json", + "value": {"arg": "value"} + }) + ); + + let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized, input); + } + + #[test] + fn test_language_model_tool_use_input_round_trips_text() { + use serde_json::json; + + let input = LanguageModelToolUseInput::Text("raw custom input".to_string()); + let serialized = serde_json::to_value(&input).unwrap(); + assert_eq!( + serialized, + json!({ + "type": "text", + "value": "raw custom input" + }) + ); + + let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized, input); + } + + #[test] + fn test_language_model_tool_use_input_parse() { + use serde_json::json; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestInput { + arg: String, + } + + let parsed: TestInput = LanguageModelToolUseInput::Json(json!({"arg": "value"})) + .parse() + .unwrap(); + assert_eq!( + parsed, + TestInput { + arg: "value".to_string() + } + ); + + let error = LanguageModelToolUseInput::Text("raw custom input".to_string()) + .parse::() + .unwrap_err(); + assert!( + error + .to_string() + .contains("custom tool text input cannot be parsed as JSON") + ); + } + + #[test] + fn test_language_model_tool_use_input_deserializes_legacy_plain_json_as_json() { + use serde_json::json; + + let deserialized: LanguageModelToolUseInput = + serde_json::from_value(json!({"arg": "value"})).unwrap(); + assert_eq!( + deserialized, + LanguageModelToolUseInput::Json(json!({"arg": "value"})) + ); + + let deserialized: LanguageModelToolUseInput = + serde_json::from_value(json!("legacy string argument")).unwrap(); + assert_eq!( + deserialized, + LanguageModelToolUseInput::Json(json!("legacy string argument")) + ); + } + #[test] fn test_language_model_tool_use_round_trip_with_signature() { use serde_json::json; @@ -663,7 +876,7 @@ mod tests { id: LanguageModelToolUseId::from("round_trip_id"), name: "round_trip_tool".into(), raw_input: json!({"key": "value"}).to_string(), - input: json!({"key": "value"}), + input: LanguageModelToolUseInput::Json(json!({"key": "value"})), is_input_complete: true, thought_signature: Some("round_trip_sig".to_string()), }; @@ -684,7 +897,7 @@ mod tests { id: LanguageModelToolUseId::from("no_sig_id"), name: "no_sig_tool".into(), raw_input: json!({"arg": "value"}).to_string(), - input: json!({"arg": "value"}), + input: LanguageModelToolUseInput::Json(json!({"arg": "value"})), is_input_complete: true, thought_signature: None, }; diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index 61696f4158ab09..a661874b0eb329 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use crate::role::Role; -use crate::{LanguageModelToolUse, LanguageModelToolUseId, SharedString}; +use crate::{ + LanguageModelToolUse, LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, +}; /// Dimensions of a `LanguageModelImage` #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -346,8 +348,52 @@ impl LanguageModelRequestMessage { pub struct LanguageModelRequestTool { pub name: String, pub description: String, - pub input_schema: serde_json::Value, - pub use_input_streaming: bool, + pub input: LanguageModelRequestToolInput, +} + +impl LanguageModelRequestTool { + pub fn function( + name: String, + description: String, + input_schema: serde_json::Value, + use_input_streaming: bool, + ) -> Self { + Self { + name, + description, + input: LanguageModelRequestToolInput::Function { + input_schema, + use_input_streaming, + }, + } + } +} + +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)] +pub enum LanguageModelRequestToolInput { + Function { + input_schema: serde_json::Value, + use_input_streaming: bool, + }, + Custom { + format: Option, + }, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub enum LanguageModelCustomToolFormat { + Text, + Grammar { + syntax: LanguageModelCustomToolGrammarSyntax, + definition: String, + }, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LanguageModelCustomToolGrammarSyntax { + Lark, + Regex, } #[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)] @@ -389,6 +435,25 @@ pub struct LanguageModelRequest { pub compact_at_tokens: Option, } +impl LanguageModelRequest { + pub fn contains_custom_tool_input(&self) -> bool { + self.tools + .iter() + .any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. })) + || self.messages.iter().any(|message| { + message.content.iter().any(|content| { + matches!( + content, + MessageContent::ToolUse(LanguageModelToolUse { + input: LanguageModelToolUseInput::Text(_), + .. + }) + ) + }) + }) + } +} + #[derive( Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema, )] diff --git a/crates/language_model_core/src/tool_schema.rs b/crates/language_model_core/src/tool_schema.rs index 13e6e665244242..49c5d4bbe75786 100644 --- a/crates/language_model_core/src/tool_schema.rs +++ b/crates/language_model_core/src/tool_schema.rs @@ -82,6 +82,8 @@ pub fn adapt_schema_to_format( obj.remove("description"); } + resolve_refs(json)?; + match format { LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json), LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json), @@ -106,6 +108,98 @@ fn preprocess_json_schema(json: &mut Value) -> Result<()> { Ok(()) } +/// Inlines same-document `$ref`s from `$defs`/`definitions` and removes those. +fn resolve_refs(json: &mut Value) -> Result<()> { + let Some(root_obj) = json.as_object_mut() else { + return Ok(()); + }; + + let defs = root_obj.remove("$defs"); + let legacy_defs = root_obj.remove("definitions"); + if defs.is_none() && legacy_defs.is_none() { + return Ok(()); + } + + resolve_refs_recursive(json, defs.as_ref(), legacy_defs.as_ref(), &mut Vec::new()) +} + +fn resolve_refs_recursive( + value: &mut Value, + defs: Option<&Value>, + legacy_defs: Option<&Value>, + visiting: &mut Vec, +) -> Result<()> { + match value { + Value::Object(obj) => { + if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) { + // Guard against cycles (A -> B -> A, or self-referential + // schemas like a Tree node whose children are Trees) + if visiting.iter().any(|v| v == ref_str) { + *obj = Map::new(); + return Ok(()); + } + + let (defs_key, name) = parse_ref(ref_str)?; + let defs_for_key = match defs_key { + "$defs" => defs, + "definitions" => legacy_defs, + _ => None, + }; + let Some(def) = defs_for_key.and_then(|defs| defs.get(name)) else { + anyhow::bail!("$ref target not found in {defs_key}: {ref_str}"); + }; + + let ref_owned = ref_str.to_string(); + + // Inline the referenced definition into the current object. + let mut resolved = def.clone(); + if let Value::Object(resolved_obj) = &mut resolved { + for (key, val) in obj.iter() { + if key != "$ref" { + resolved_obj.insert(key.clone(), val.clone()); + } + } + } + *value = resolved; + + visiting.push(ref_owned); + let result = resolve_refs_recursive(value, defs, legacy_defs, visiting); + visiting.pop(); + return result; + } + + let keys: Vec = obj.keys().cloned().collect(); + for key in keys { + if let Some(child) = obj.get_mut(&key) { + resolve_refs_recursive(child, defs, legacy_defs, visiting)?; + } + } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + resolve_refs_recursive(item, defs, legacy_defs, visiting)?; + } + } + _ => {} + } + Ok(()) +} + +/// Parses a same-document `$ref` like `#/$defs/Foo` or `#/definitions/Foo`. +/// Returns `(defs_key, name)` where `defs_key` is the top-level key the +/// definition was looked up under, and `name` is the definition name. +fn parse_ref(ref_str: &str) -> Result<(&'static str, &str)> { + if let Some(name) = ref_str.strip_prefix("#/$defs/") { + return Ok(("$defs", name)); + } + if let Some(name) = ref_str.strip_prefix("#/definitions/") { + return Ok(("definitions", name)); + } + anyhow::bail!( + "Unsupported $ref format (only `#/$defs/` and `#/definitions/` are supported): {ref_str}" + ); +} + fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { if let Value::Object(obj) = json { const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"]; @@ -305,6 +399,7 @@ fn collapse_nullable_only_any_of(obj: &mut Map) { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use serde_json::json; fn obj(value: Value) -> Map { @@ -787,6 +882,307 @@ mod tests { assert!(adapt_to_json_schema_subset(&mut json).is_err()); } + #[test] + fn test_refs_are_resolved_via_adapt_schema_to_format() { + let mut json = json!({ + "type": "object", + "properties": { + "parent": { + "$ref": "#/$defs/pageParent" + }, + "title": { + "type": "string", + "description": "Page title" + } + }, + "required": ["parent"], + "$defs": { + "pageParent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Parent type" + } + }, + "required": ["type"] + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + let expected = json!({ + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Parent type" + } + }, + "required": ["type"] + }, + "title": { + "type": "string", + "description": "Page title" + } + }, + "required": ["parent"], + }); + assert_eq!(json, expected); + } + + #[test] + fn test_refs_fail_for_unsupported_prefix() { + let mut json = json!({ + "type": "object", + "properties": { + "child": { + "$ref": "https://example.com/schema.json#/User" + } + }, + "$defs": { + "User": { "type": "string" } + } + }); + + assert!( + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset) + .is_err() + ); + } + + #[test] + fn test_refs_fail_for_missing_definition() { + let mut json = json!({ + "type": "object", + "properties": { + "child": { + "$ref": "#/$defs/NonExistent" + } + }, + "$defs": { + "Existing": { "type": "string" } + } + }); + + assert!( + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset) + .is_err() + ); + } + + #[test] + fn test_refs_in_defs_are_resolved() { + // A definition that itself references another definition. + let mut json = json!({ + "type": "object", + "properties": { + "parent": { + "$ref": "#/$defs/pageParent" + } + }, + "$defs": { + "pageParent": { + "type": "object", + "properties": { + "database_id": { + "$ref": "#/$defs/databaseId" + } + } + }, + "databaseId": { + "type": "string", + "description": "A database ID" + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + // The nested $ref in pageParent -> databaseId should be resolved. + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "database_id": { + "type": "string", + "description": "A database ID" + } + } + } + } + }) + ); + } + + #[test] + fn test_refs_resolve_when_both_defs_and_definitions_exist() { + let mut json = json!({ + "type": "object", + "properties": { + "modern": { + "$ref": "#/$defs/Modern" + }, + "legacy": { + "$ref": "#/definitions/Legacy" + } + }, + "$defs": { + "Modern": { + "type": "string" + } + }, + "definitions": { + "Legacy": { + "type": "number" + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "modern": { + "type": "string" + }, + "legacy": { + "type": "number" + } + } + }) + ); + } + + #[test] + fn test_refs_in_array_items_are_resolved() { + let mut json = json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/itemDef" + } + } + }, + "$defs": { + "itemDef": { + "type": "string", + "description": "An item" + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string", + "description": "An item" + } + } + } + }) + ); + } + + #[test] + fn test_self_referential_ref_is_replaced_with_empty_schema() { + // A common pattern: a Tree node with children of the same type. + let mut json = json!({ + "type": "object", + "properties": { + "root": { "$ref": "#/$defs/Tree" } + }, + "$defs": { + "Tree": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/Tree" } + } + } + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset) + .expect("self-referential $ref should not error"); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "root": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": {} + } + } + } + } + }) + ); + } + + #[test] + fn test_ref_sibling_properties_are_preserved() { + // JSON Schema draft 2019-09+ allows sibling properties alongside + // `$ref`. They must be merged into the resolved definition rather than + // discarded, with siblings overriding the definition's keys. + let mut json = json!({ + "type": "object", + "properties": { + "child": { + "$ref": "#/$defs/childDef", + "description": "Local description overrides def" + } + }, + "$defs": { + "childDef": { + "type": "string", + "description": "Def description", + "minLength": 1 + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + assert_eq!( + json["properties"]["child"], + json!({ + "type": "string", + "description": "Local description overrides def", + "minLength": 1 + }) + ); + } + #[test] fn test_preprocess_json_schema_adds_additional_properties() { let mut json = json!({ diff --git a/crates/language_model_core/src/util.rs b/crates/language_model_core/src/util.rs index 3db2e0b76fd760..3178deccbd0363 100644 --- a/crates/language_model_core/src/util.rs +++ b/crates/language_model_core/src/util.rs @@ -48,6 +48,13 @@ pub fn parse_prompt_too_long(message: &str) -> Option { .ok() } +/// Recognizes OpenAI-style context window overflow errors, which arrive either +/// with the `context_length_exceeded` error code or a "Your input exceeds the +/// context window of this model" message. +pub fn is_context_window_exceeded_message(message: &str) -> bool { + message.contains("context_length_exceeded") || message.contains("exceeds the context window") +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 57a0d530e451b2..8da9eb332ed79c 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -18,6 +18,7 @@ anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true aws-config = { workspace = true, features = ["behavior-version-latest"] } aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] } +aws-sigv4.workspace = true aws_http_client.workspace = true base64.workspace = true bedrock = { workspace = true, features = ["schemars"] } diff --git a/crates/language_models/src/api_key_editor.rs b/crates/language_models/src/api_key_editor.rs deleted file mode 100644 index d6a9182a662b4b..00000000000000 --- a/crates/language_models/src/api_key_editor.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::rc::Rc; - -use anyhow::Result; -use gpui::{App, Context, Entity, Subscription, Task, Window}; -use language_model::ApiKeyState; -use ui::{Tooltip, prelude::*}; -use ui_input::InputField; - -/// The current credential state of a single-API-key provider, as reported by the -/// provider when constructing an [`ApiKeyEditor`]. -pub enum ApiKeyStatus { - /// No key is configured; show the input field. - Unset, - /// A key is configured via the UI; show a "configured" row with a reset. - Configured, - /// The key comes from an environment variable and can't be edited here. - FromEnvVar(SharedString), -} - -/// Maps a provider's [`ApiKeyState`] to the [`ApiKeyStatus`] the editor renders. -/// Shared so the API-key providers don't each duplicate this mapping. -pub fn api_key_status(state: &ApiKeyState) -> ApiKeyStatus { - if state.is_from_env_var() { - ApiKeyStatus::FromEnvVar(state.env_var_name().clone()) - } else if state.has_key() { - ApiKeyStatus::Configured - } else { - ApiKeyStatus::Unset - } -} - -/// A compact, reusable control for editing a provider's single API key, intended -/// to be returned from `LanguageModelProvider::configuration_view_v2` as an -/// inline control. -/// -/// It is deliberately provider-agnostic: the provider supplies closures that -/// read the current [`ApiKeyStatus`] and store/clear the key against its own -/// state, so all credential knowledge stays in the provider. -pub struct ApiKeyEditor { - input: Entity, - api_key_url: SharedString, - status: Rc ApiKeyStatus>, - set_key: Rc Task>>, - reset_key: Rc Task>>, - _subscription: Subscription, -} - -impl ApiKeyEditor { - pub fn new( - state: Entity, - api_key_url: impl Into, - placeholder: &str, - status: impl Fn(&S, &App) -> ApiKeyStatus + 'static, - set_key: impl Fn(&Entity, String, &mut App) -> Task> + 'static, - reset_key: impl Fn(&Entity, &mut App) -> Task> + 'static, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let input = cx.new(|cx| { - InputField::new(window, cx, placeholder) - .masked(true) - .tab_index(0) - }); - let subscription = cx.observe(&state, |_, _, cx| cx.notify()); - - let status_state = state.clone(); - let set_state = state.clone(); - Self { - input, - api_key_url: api_key_url.into(), - status: Rc::new(move |cx| status(status_state.read(cx), cx)), - set_key: Rc::new(move |key, cx| set_key(&set_state, key, cx)), - reset_key: Rc::new(move |cx| reset_key(&state, cx)), - _subscription: subscription, - } - } - - fn save(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let key = self.input.read(cx).text(cx).trim().to_string(); - if key.is_empty() { - return; - } - self.input - .update(cx, |input, cx| input.set_text("", window, cx)); - (self.set_key.clone())(key, cx).detach_and_log_err(cx); - } - - fn reset(&mut self, cx: &mut Context) { - (self.reset_key.clone())(cx).detach_and_log_err(cx); - } - - fn render_where_to_find_key(&self) -> impl IntoElement { - let url = self.api_key_url.clone(); - let click_url = url.to_string(); - h_flex() - .id("where-to-find-key") - .gap_0p5() - .cursor_pointer() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Where to find key") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .tooltip(Tooltip::text(format!("Create an API key at {url}"))) - .on_click(move |_, _window, cx| cx.open_url(&click_url)) - } -} - -impl Render for ApiKeyEditor { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - match (self.status)(cx) { - ApiKeyStatus::FromEnvVar(env_var_name) => Label::new(format!("Set via {env_var_name}")) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element(), - ApiKeyStatus::Configured => h_flex() - .gap_2() - .items_center() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child( - Label::new("Configured") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Button::new("reset-api-key", "Reset") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .tab_index(0isize) - .on_click(cx.listener(|this, _, _window, cx| this.reset(cx))), - ) - .into_any_element(), - ApiKeyStatus::Unset => v_flex() - .w_full() - .gap_1() - .child(self.render_where_to_find_key()) - .child( - div() - .w_full() - .on_action(cx.listener(Self::save)) - .child(self.input.clone()), - ) - .into_any_element(), - } - } -} diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 1133ee2d47760e..ce968e3d611184 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -5,17 +5,13 @@ use client::{Client, UserStore}; use collections::{HashMap, HashSet}; use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; -use language_model::{ - ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID, -}; +use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; -mod api_key_editor; pub mod extension; pub mod provider; mod settings; -pub use crate::api_key_editor::{ApiKeyEditor, ApiKeyStatus, api_key_status}; pub use crate::extension::init_proxy as init_extension_proxy; use crate::provider::anthropic::AnthropicLanguageModelProvider; @@ -142,50 +138,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .detach(); } -/// Recomputes and sets the [`LanguageModelRegistry`]'s environment fallback -/// model based on currently authenticated providers. -/// -/// Prefers the Zed cloud provider so that, once the user is signed in, we -/// always pick a Zed-hosted model over models from other authenticated -/// providers in the environment. If the Zed cloud provider is authenticated -/// but hasn't finished loading its models yet, we don't fall back to another -/// provider to avoid flickering between providers during sign in. -pub fn update_environment_fallback_model(cx: &mut App) { - let registry = LanguageModelRegistry::global(cx); - let fallback_model = { - let registry = registry.read(cx); - let cloud_provider = registry.provider(&ZED_CLOUD_PROVIDER_ID); - if cloud_provider - .as_ref() - .is_some_and(|provider| provider.is_authenticated(cx)) - { - cloud_provider.and_then(|provider| { - let model = provider - .default_model(cx) - .or_else(|| provider.recommended_models(cx).first().cloned())?; - Some(ConfiguredModel { provider, model }) - }) - } else { - registry - .providers() - .iter() - .filter(|provider| provider.is_authenticated(cx)) - .find_map(|provider| { - let model = provider - .default_model(cx) - .or_else(|| provider.recommended_models(cx).first().cloned())?; - Some(ConfiguredModel { - provider: provider.clone(), - model, - }) - }) - } - }; - registry.update(cx, |registry, cx| { - registry.set_environment_fallback_model(fallback_model, cx); - }); -} - #[derive(Default, PartialEq, Eq)] struct CompatibleProviders(HashMap, CompatibleProviderKind>); diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 46ef381f3127df..a3df6cfe88624c 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -5,21 +5,19 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyState, AuthenticateError, - ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyConfiguration, ApiKeyState, + AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - ProviderConfigurationView, RateLimiter, env_var, + ProviderSettingsView, RateLimiter, env_var, }; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; pub use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}; pub use settings::AnthropicAvailableModel as AvailableModel; @@ -275,43 +273,19 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://console.anthropic.com/settings/keys".into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://console.anthropic.com/settings/keys", - "sk-ant-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } fn fast_mode_confirmation(&self, _cx: &App) -> Option { @@ -353,12 +327,24 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: settings::ModelMode::Thinking { budget_tokens } => { AnthropicModelMode::Thinking { budget_tokens } } + settings::ModelMode::Adaptive => AnthropicModelMode::AdaptiveThinking, }; let supports_thinking = matches!( mode, AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking ); let supports_adaptive_thinking = matches!(mode, AnthropicModelMode::AdaptiveThinking); + let supports_speed = available + .supports_fast_mode + .unwrap_or_else(|| anthropic::supports_fast_mode(&available.name)); + let mut extra_beta_headers = available.extra_beta_headers.clone(); + if supports_speed + && !extra_beta_headers + .iter() + .any(|header| header.trim() == anthropic::FAST_MODE_BETA_HEADER) + { + extra_beta_headers.push(anthropic::FAST_MODE_BETA_HEADER.to_string()); + } anthropic::Model { display_name: available @@ -373,7 +359,7 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: supports_thinking, supports_adaptive_thinking, supports_images: true, - supports_speed: false, + supports_speed, supports_compaction: false, supported_effort_levels: if supports_adaptive_thinking { vec![ @@ -387,7 +373,76 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: vec![] }, tool_override: available.tool_override.clone(), - extra_beta_headers: available.extra_beta_headers.clone(), + extra_beta_headers, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_available_model(json: &str) -> AvailableModel { + serde_json::from_str(json).expect("test fixture should parse") + } + + #[test] + fn adaptive_mode_maps_to_adaptive_thinking_with_all_effort_levels() { + let available = parse_available_model( + r#"{ + "name": "claude-opus-4-7", + "max_tokens": 1000000, + "max_output_tokens": 128000, + "mode": { "type": "adaptive" } + }"#, + ); + let model = available_model_to_anthropic_model(&available); + + assert_eq!(model.mode, AnthropicModelMode::AdaptiveThinking); + assert!(model.supports_thinking); + assert!(model.supports_adaptive_thinking); + assert_eq!( + model.supported_effort_levels, + vec![ + anthropic::Effort::Low, + anthropic::Effort::Medium, + anthropic::Effort::High, + anthropic::Effort::XHigh, + anthropic::Effort::Max, + ] + ); + } + + #[test] + fn thinking_mode_does_not_enable_adaptive() { + let available = parse_available_model( + r#"{ + "name": "claude-sonnet-4-5", + "max_tokens": 200000, + "mode": { "type": "thinking", "budget_tokens": 4096 } + }"#, + ); + let model = available_model_to_anthropic_model(&available); + + assert!(matches!(model.mode, AnthropicModelMode::Thinking { .. })); + assert!(model.supports_thinking); + assert!(!model.supports_adaptive_thinking); + assert!(model.supported_effort_levels.is_empty()); + } + + #[test] + fn default_mode_disables_thinking() { + let available = parse_available_model( + r#"{ + "name": "claude-3-5-haiku", + "max_tokens": 200000 + }"#, + ); + let model = available_model_to_anthropic_model(&available); + + assert_eq!(model.mode, AnthropicModelMode::Default); + assert!(!model.supports_thinking); + assert!(!model.supports_adaptive_thinking); + assert!(model.supported_effort_levels.is_empty()); } } @@ -554,14 +609,17 @@ impl LanguageModel for AnthropicModel { > { let has_tools = !request.tools.is_empty(); let request_id = self.model.request_id(has_tools).to_string(); - let mut request = into_anthropic( + let mut request = match into_anthropic( request, request_id, self.model.default_temperature, self.model.max_output_tokens, self.model.mode.clone(), AnthropicPromptCacheMode::Automatic, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; if !self.model.supports_speed { request.speed = None; } @@ -573,144 +631,3 @@ impl LanguageModel for AnthropicModel { async move { Ok(future.await?.boxed()) }.boxed() } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, - target_agent: ConfigurationViewTargetAgent, -} - -impl ConfigurationView { - const PLACEHOLDER_TEXT: &'static str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; - - fn new( - state: Entity, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut Context, - ) -> Self { - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn({ - let state = state.clone(); - async move |this, cx| { - let task = state.update(cx, |state, cx| state.authenticate(cx)); - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor: cx.new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_TEXT)), - state, - load_credentials_task, - target_agent, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = AnthropicLanguageModelProvider::api_url(cx); - if api_url == ANTHROPIC_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { - ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(), - ConfigurationViewTargetAgent::Other(agent) => agent.clone(), - }))) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys")) - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ) - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small) - .color(Color::Muted) - .mt_0p5(), - ) - .into_any_element() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!( - "To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable." - )) - }) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 38f31d2d7a83f5..49813405000f69 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -3,13 +3,14 @@ use anthropic::{AnthropicError, AnthropicModelMode}; use anyhow::Result; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, RateLimiter, + LanguageModelRequest, LanguageModelToolChoice, ProviderSettingsView, RateLimiter, + SubPageProviderSettings, }; use settings::Settings; use std::sync::Arc; @@ -53,8 +54,13 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: settings::ModelMode::Thinking { budget_tokens } => { AnthropicModelMode::Thinking { budget_tokens } } + settings::ModelMode::Adaptive => AnthropicModelMode::AdaptiveThinking, }; - let supports_thinking = matches!(mode, AnthropicModelMode::Thinking { .. }); + let supports_thinking = matches!( + mode, + AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking + ); + let supports_adaptive_thinking = matches!(mode, AnthropicModelMode::AdaptiveThinking { .. }); anthropic::Model { display_name: available @@ -67,7 +73,7 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: default_temperature: available.default_temperature.unwrap_or(1.0), mode, supports_thinking, - supports_adaptive_thinking: false, + supports_adaptive_thinking, supports_images: available.capabilities.images, supports_speed: false, supports_compaction: false, @@ -181,27 +187,27 @@ impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| { - ApiCompatibleProviderConfigurationView::new( - self.state.clone(), - "Anthropic", - API_KEY_PLACEHOLDER, - window, - cx, - ) - }) - .into() + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage(SubPageProviderSettings::new( + move |window, cx| { + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + state.clone(), + "Anthropic", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + }, + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -332,14 +338,17 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { > { let has_tools = !request.tools.is_empty(); let request_id = self.model.request_id(has_tools).to_string(); - let mut request = into_anthropic( + let mut request = match into_anthropic( request, request_id, self.model.default_temperature, self.model.max_output_tokens, self.model.mode.clone(), self.cache_mode, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; if !self.model.supports_speed { request.speed = None; } diff --git a/crates/language_models/src/provider/api_compatible.rs b/crates/language_models/src/provider/api_compatible.rs index e186f0baf956d0..f8b4e731c8f24d 100644 --- a/crates/language_models/src/provider/api_compatible.rs +++ b/crates/language_models/src/provider/api_compatible.rs @@ -178,6 +178,23 @@ impl ApiCompatibleProviderConfigurationView .detach_and_log_err(cx); } + fn remove_provider(&mut self, _: &mut Window, cx: &mut Context) { + let id = self.state.read(cx).id.clone(); + let fs = ::global(cx); + settings::update_settings_file(fs, cx, move |settings, _| { + let Some(language_models) = settings.language_models.as_mut() else { + return; + }; + + if let Some(providers) = language_models.openai_compatible.as_mut() { + providers.remove(id.as_ref()); + } + if let Some(providers) = language_models.anthropic_compatible.as_mut() { + providers.remove(id.as_ref()); + } + }); + } + fn should_render_editor(&self, cx: &Context) -> bool { !self.state.read(cx).is_authenticated() } @@ -256,7 +273,26 @@ impl Render for ApiCompatibleProviderConfigura if self.load_credentials_task.is_some() { div().child(Label::new("Loading credentials…")).into_any() } else { - v_flex().size_full().child(api_key_section).into_any() + v_flex() + .size_full() + .gap_4() + .child(api_key_section) + .child( + h_flex().w_full().justify_end().child( + Button::new("remove-compatible-provider", "Remove Provider") + .style(ButtonStyle::OutlinedGhost) + .label_size(LabelSize::Small) + .start_icon( + Icon::new(IconName::Trash) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.remove_provider(window, cx) + })), + ), + ) + .into_any() } } } diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 755d23f52a22a2..b686a3d11f3148 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -5,8 +5,11 @@ use anyhow::{Context as _, Result, anyhow}; use async_lock::OnceCell; use aws_config::stalled_stream_protection::StalledStreamProtectionConfig; use aws_config::{BehaviorVersion, Region}; +use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider}; use aws_credential_types::{Credentials, Token}; use aws_http_client::AwsHttpClient; +use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign}; +use aws_sigv4::sign::v4; use bedrock::BedrockSystemContentBlock; use bedrock::bedrock_client::Client as BedrockClient; use bedrock::bedrock_client::config::timeout::TimeoutConfig; @@ -20,37 +23,53 @@ use bedrock::{ BedrockStreamingResponse, BedrockThinkingBlock, BedrockThinkingTextBlock, BedrockTool, BedrockToolChoice, BedrockToolConfig, BedrockToolInputSchema, BedrockToolResultBlock, BedrockToolResultContentBlock, BedrockToolResultStatus, BedrockToolSpec, BedrockToolUseBlock, - Model, value_to_aws_document, + ConverseModel, MantleModel, MantleProtocol, value_to_aws_document, }; use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; -use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; +use futures::{ + AsyncBufReadExt, AsyncReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, io::BufReader, + stream::BoxStream, +}; use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window, - actions, + App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window, actions, }; use gpui_tokio::Tokio; -use http_client::HttpClient; +use http_client::{ + AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, + http::{HeaderValue, header::AUTHORIZATION}, +}; use language_model::{ - AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolUse, MessageContent, RateLimiter, Role, TokenUsage, env_var, + AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, + LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, + SubPageProviderSettings, TokenUsage, env_var, }; +use open_ai::responses::Request as OpenAiResponseRequest; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore}; +use settings::{ + BedrockAvailableModel as AvailableModel, BedrockMantleAvailableModel as MantleAvailableModel, + Settings, SettingsStore, +}; use std::sync::LazyLock; +use std::time::SystemTime; use strum::{EnumIter, IntoEnumIterator, IntoStaticStr}; use ui::{ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; use crate::AllLanguageModelSettings; -use http_client::CustomHeaders; +use crate::provider::open_ai::{ + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, +}; use language_model::util::{fix_streamed_json, parse_tool_arguments}; +use open_ai::{ReasoningEffort, RequestError, ResponseStreamEvent}; actions!(bedrock, [Tab, TabPrev]); @@ -116,6 +135,7 @@ impl BedrockCredentials { #[derive(Default, Clone, Debug, PartialEq)] pub struct AmazonBedrockSettings { pub available_models: Vec, + pub mantle_available_models: Vec, pub custom_headers: CustomHeaders, pub region: Option, pub endpoint: Option, @@ -151,6 +171,13 @@ impl From for BedrockAuthMethod { } } +fn mantle_protocol_from_settings(value: settings::BedrockMantleProtocolContent) -> MantleProtocol { + match value { + settings::BedrockMantleProtocolContent::ChatCompletions => MantleProtocol::ChatCompletions, + settings::BedrockMantleProtocolContent::Responses => MantleProtocol::Responses, + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ModelMode { @@ -200,6 +227,115 @@ static ZED_BEDROCK_REGION_VAR: LazyLock = env_var!("ZED_AWS_REGION"); static ZED_AWS_ENDPOINT_VAR: LazyLock = env_var!("ZED_AWS_ENDPOINT"); static ZED_BEDROCK_BEARER_TOKEN_VAR: LazyLock = env_var!("ZED_BEDROCK_BEARER_TOKEN"); +/// AWS Regions where the `bedrock-mantle` endpoint is available. +/// See . +const MANTLE_SUPPORTED_REGIONS: &[&str] = &[ + "us-east-2", + "us-east-1", + "us-west-2", + "ap-southeast-3", + "ap-south-1", + "ap-southeast-2", + "ap-northeast-1", + "eu-central-1", + "eu-west-1", + "eu-west-2", + "eu-south-1", + "eu-north-1", + "sa-east-1", + "us-gov-west-1", +]; + +fn mantle_endpoint_url(region: &str) -> String { + format!("https://bedrock-mantle.{region}.api.aws/openai/v1") +} + +enum MantleAuth { + ApiKey { api_key: String }, + SigV4 { credentials: Credentials }, +} + +impl MantleAuth { + fn apply(&self, request: &mut HttpRequest, body: &[u8], region: &str) -> Result<()> { + match self { + MantleAuth::ApiKey { api_key } => { + let value = HeaderValue::from_str(&format!("Bearer {}", api_key.trim())) + .context("building Mantle bearer token authorization header")?; + request.headers_mut().insert(AUTHORIZATION, value); + } + MantleAuth::SigV4 { credentials } => { + sign_mantle_request_sigv4(request, body, credentials, region)?; + } + } + + Ok(()) + } +} + +fn sign_mantle_request_sigv4( + request: &mut HttpRequest, + body: &[u8], + credentials: &Credentials, + region: &str, +) -> Result<()> { + sign_mantle_request_sigv4_at(request, body, credentials, region, SystemTime::now()) +} + +fn sign_mantle_request_sigv4_at( + request: &mut HttpRequest, + body: &[u8], + credentials: &Credentials, + region: &str, + time: SystemTime, +) -> Result<()> { + if !request + .headers() + .contains_key(http_client::http::header::HOST) + && let Some(authority) = request.uri().authority() + { + let host = HeaderValue::from_str(authority.as_str()) + .context("invalid host header derived from Mantle request URI")?; + request + .headers_mut() + .insert(http_client::http::header::HOST, host); + } + + let identity = credentials.clone().into(); + let signing_params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder() + .identity(&identity) + .region(region) + .name("bedrock-mantle") + .time(time) + .settings(SigningSettings::default()) + .build() + .context("building Mantle SigV4 signing params")? + .into(); + + let method = request.method().as_str(); + let uri = request.uri().to_string(); + let headers = request + .headers() + .iter() + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.as_str(), value)) + .with_context(|| format!("header {name} is not valid UTF-8 and cannot be signed")) + }) + .collect::>>()?; + + let signable_request = + SignableRequest::new(method, uri, headers.into_iter(), SignableBody::Bytes(body)) + .context("constructing Mantle SigV4 request")?; + + let (instructions, _signature) = sign(signable_request, &signing_params) + .context("signing Mantle request with SigV4")? + .into_parts(); + instructions.apply_to_request_http1x(request); + + Ok(()) +} + pub struct State { /// The resolved authentication method. Settings take priority over UX credentials. auth: Option, @@ -407,6 +543,7 @@ impl State { pub struct BedrockLanguageModelProvider { http_client: AwsHttpClient, + plain_http_client: Arc, handle: tokio::runtime::Handle, state: Entity, } @@ -428,13 +565,14 @@ impl BedrockLanguageModelProvider { }); Self { - http_client: AwsHttpClient::new(http_client), + http_client: AwsHttpClient::new(http_client.clone()), + plain_http_client: http_client, handle: Tokio::handle(cx), state, } } - fn create_language_model(&self, model: bedrock::Model) -> Arc { + fn create_language_model(&self, model: bedrock::ConverseModel) -> Arc { Arc::new(BedrockModel { id: LanguageModelId::from(model.id().to_string()), model, @@ -445,6 +583,17 @@ impl BedrockLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + fn create_mantle_language_model(&self, model: bedrock::MantleModel) -> Arc { + Arc::new(BedrockMantleModel { + id: LanguageModelId::from(model.id().to_string()), + model, + http_client: self.plain_http_client.clone(), + state: self.state.clone(), + credentials_provider: Arc::new(OnceCell::new()), + request_limiter: RateLimiter::new(4), + }) + } } impl LanguageModelProvider for BedrockLanguageModelProvider { @@ -461,32 +610,29 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { } fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(bedrock::Model::default())) + Some(self.create_language_model(bedrock::ConverseModel::default())) } fn default_fast_model(&self, cx: &App) -> Option> { let region = self.state.read(cx).get_region(); - Some(self.create_language_model(bedrock::Model::default_fast(region.as_str()))) + Some(self.create_language_model(bedrock::ConverseModel::default_fast(region.as_str()))) } fn provided_models(&self, cx: &App) -> Vec> { + let bedrock_settings = &AllLanguageModelSettings::get_global(cx).bedrock; let mut models = BTreeMap::default(); - for model in bedrock::Model::iter() { - if !matches!(model, bedrock::Model::Custom { .. }) { + for model in bedrock::ConverseModel::iter() { + if !matches!(model, bedrock::ConverseModel::Custom { .. }) { models.insert(model.id().to_string(), model); } } // Override with available models from settings - for model in AllLanguageModelSettings::get_global(cx) - .bedrock - .available_models - .iter() - { + for model in bedrock_settings.available_models.iter() { models.insert( model.name.clone(), - bedrock::Model::Custom { + bedrock::ConverseModel::Custom { name: model.name.clone(), display_name: model.display_name.clone(), max_tokens: model.max_tokens, @@ -502,10 +648,43 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { ); } - models + let mut models: Vec> = models .into_values() .map(|model| self.create_language_model(model)) - .collect() + .collect(); + + let mut mantle_models = BTreeMap::default(); + + for model in bedrock::MantleModel::iter() { + if !matches!(model, bedrock::MantleModel::Custom { .. }) { + mantle_models.insert(model.id().to_string(), model); + } + } + + // Override with available Mantle models from settings + for model in bedrock_settings.mantle_available_models.iter() { + mantle_models.insert( + model.name.clone(), + bedrock::MantleModel::Custom { + name: model.name.clone(), + display_name: model.display_name.clone(), + max_tokens: model.max_tokens, + max_output_tokens: model.max_output_tokens, + protocol: mantle_protocol_from_settings(model.protocol), + supports_tools: model.supports_tools.unwrap_or(false), + supports_images: model.supports_images.unwrap_or(false), + supports_thinking: model.supports_thinking.unwrap_or(false), + }, + ); + } + + models.extend( + mantle_models + .into_values() + .map(|model| self.create_mantle_language_model(model)), + ); + + models } fn is_authenticated(&self, cx: &App) -> bool { @@ -516,18 +695,17 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_auth(cx)) + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials. Mantle-only models (e.g. GPT-5.5, GPT-5.4, Grok 4.3) additionally require IAM permissions for the `bedrock-mantle` endpoint.".into(), + )), + )) } } @@ -541,7 +719,7 @@ impl LanguageModelProviderState for BedrockLanguageModelProvider { struct BedrockModel { id: LanguageModelId, - model: Model, + model: ConverseModel, http_client: AwsHttpClient, handle: tokio::runtime::Handle, client: OnceCell, @@ -671,6 +849,18 @@ impl LanguageModel for BedrockModel { self.model.supports_thinking() } + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self + .model + .id() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + fn supported_effort_levels(&self) -> Vec { if self.model.supports_adaptive_thinking() { vec![ @@ -747,6 +937,13 @@ impl LanguageModel for BedrockModel { LanguageModelCompletionError, >, > { + if request.contains_custom_tool_input() { + return async move { + Err(anyhow::anyhow!("Bedrock does not support custom tools").into()) + } + .boxed(); + } + let (region, allow_global, guardrail_identifier, guardrail_version) = cx.read_entity(&self.state, |state, _cx| { let (gid, gv) = state.get_guardrail_config(); @@ -829,6 +1026,488 @@ impl LanguageModel for BedrockModel { } } +const MANTLE_SELECTABLE_REASONING_EFFORTS: &[ReasoningEffort] = &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, +]; + +fn mantle_default_reasoning_effort(model: &MantleModel) -> Option { + model.supports_thinking().then_some(ReasoningEffort::Medium) +} + +fn mantle_selected_reasoning_effort( + request: &LanguageModelRequest, + model: &MantleModel, +) -> Option { + if !model.supports_thinking() { + return None; + } + + if request.thinking_allowed { + request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .filter(|effort| *effort != ReasoningEffort::None) + .or_else(|| mantle_default_reasoning_effort(model)) + } else { + Some(ReasoningEffort::None) + } +} + +fn mantle_supported_effort_levels(model: &MantleModel) -> Vec { + let Some(default_effort) = mantle_default_reasoning_effort(model) else { + return Vec::new(); + }; + + MANTLE_SELECTABLE_REASONING_EFFORTS + .iter() + .copied() + .map(|effort| LanguageModelEffortLevel { + name: effort.label().into(), + value: effort.value().into(), + is_default: effort == default_effort, + }) + .collect() +} + +/// Special-cases Mantle authorization failures with a message that points at +/// the separate `bedrock-mantle` IAM policy namespace instead of regular +/// `bedrock-runtime` permissions. +fn map_mantle_error(model: &MantleModel, error: RequestError) -> LanguageModelCompletionError { + if let RequestError::HttpResponseError { status_code, .. } = &error + && *status_code == http_client::http::StatusCode::FORBIDDEN + { + return LanguageModelCompletionError::PermissionError { + provider: PROVIDER_NAME, + message: format!( + "Bedrock Mantle denied this request for {}. Mantle-only models require IAM \ + permissions for the `bedrock-mantle` endpoint (for example via the \ + `AmazonBedrockMantleInferenceAccess` managed policy) in addition to whatever \ + permissions your existing Bedrock credentials already have.", + model.display_name() + ), + }; + } + error.into() +} + +/// Resolves an AWS credentials provider for profile/SSO/automatic auth. +/// Cached in `cell` since building it may read config files from disk; +/// credentials themselves are still re-resolved on every call. Async so this +/// never blocks the foreground thread (unlike `BedrockModel::get_or_init_client`). +async fn resolve_mantle_credentials_provider( + cell: &OnceCell, + profile_name: Option, + region: String, +) -> Result { + let provider = cell + .get_or_try_init(move || async move { + let mut config_builder = + aws_config::defaults(BehaviorVersion::latest()).region(Region::new(region)); + + if let Some(profile_name) = profile_name.filter(|name| !name.is_empty()) { + config_builder = config_builder.profile_name(profile_name); + } + + let config = config_builder.load().await; + config + .credentials_provider() + .context("no AWS credentials provider is configured") + }) + .await + .context("resolving AWS credentials for Bedrock Mantle")?; + Ok(provider.clone()) +} + +/// Resolves provider settings into concrete Mantle request auth. A configured +/// Bedrock API key is sent as bearer auth; every AWS-credential-based method +/// signs the Mantle HTTP request directly with SigV4. +async fn resolve_mantle_auth( + credentials_provider: Arc>, + auth: Option, + region: String, +) -> Result { + match auth { + Some(BedrockAuth::ApiKey { api_key }) => Ok(MantleAuth::ApiKey { api_key }), + Some(BedrockAuth::IamCredentials { + access_key_id, + secret_access_key, + session_token, + }) => Ok(MantleAuth::SigV4 { + credentials: Credentials::new( + access_key_id, + secret_access_key, + session_token, + None, + "zed-bedrock-provider", + ), + }), + Some(BedrockAuth::NamedProfile { profile_name }) + | Some(BedrockAuth::SingleSignOn { profile_name }) => { + let provider = resolve_mantle_credentials_provider( + &credentials_provider, + Some(profile_name), + region.clone(), + ) + .await?; + let credentials = provider + .provide_credentials() + .await + .context("failed to resolve AWS credentials")?; + Ok(MantleAuth::SigV4 { credentials }) + } + Some(BedrockAuth::Automatic) | None => { + let provider = + resolve_mantle_credentials_provider(&credentials_provider, None, region.clone()) + .await?; + let credentials = provider + .provide_credentials() + .await + .context("failed to resolve AWS credentials")?; + Ok(MantleAuth::SigV4 { credentials }) + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum MantleChatStreamResult { + Ok(ResponseStreamEvent), + Err { error: MantleChatStreamError }, +} + +#[derive(Deserialize)] +struct MantleChatStreamError { + message: String, +} + +fn parse_mantle_chat_stream_line(line: &str) -> Result { + match serde_json::from_str(line) { + Ok(MantleChatStreamResult::Ok(response)) => Ok(response), + Ok(MantleChatStreamResult::Err { error }) => Err(anyhow!(error.message)), + Err(error) => { + log::error!( + "Failed to parse Mantle chat completion stream event: `{}`\nResponse: `{}`", + error, + line, + ); + Err(anyhow!(error)) + } + } +} + +fn parse_mantle_response_stream_line(line: &str) -> Result { + serde_json::from_str(line).map_err(|error| { + log::error!( + "Failed to parse Mantle responses stream event: `{}`\nResponse: `{}`", + error, + line, + ); + anyhow!(error) + }) +} + +async fn stream_mantle_sse( + client: &dyn HttpClient, + provider_name: &str, + url: &str, + region: &str, + auth: &MantleAuth, + request: Request, + extra_headers: &CustomHeaders, + parse_stream_line: fn(&str) -> Result, +) -> std::result::Result>, RequestError> +where + Request: Serialize, + Event: Send + 'static, +{ + let body = serde_json::to_vec(&request).map_err(|error| RequestError::Other(error.into()))?; + let mut request = HttpRequest::builder() + .method(Method::POST) + .uri(url) + .header("Content-Type", "application/json") + .extra_headers(extra_headers) + .body(AsyncBody::from(body.clone())) + .map_err(|error| RequestError::Other(error.into()))?; + + auth.apply(&mut request, &body, region) + .map_err(RequestError::Other)?; + + let mut response = client.send(request).await?; + if response.status().is_success() { + let reader = BufReader::new(response.into_body()); + Ok(reader + .lines() + .filter_map(move |line| async move { + match line { + Ok(line) => { + let line = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:"))?; + if line == "[DONE]" || line.is_empty() { + None + } else { + Some(parse_stream_line(line)) + } + } + Err(error) => Some(Err(anyhow!(error))), + } + }) + .boxed()) + } else { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(|error| RequestError::Other(error.into()))?; + + Err(RequestError::HttpResponseError { + provider: provider_name.to_owned(), + status_code: response.status(), + body, + headers: response.headers().clone(), + }) + } +} + +fn strip_unsupported_mantle_response_fields(request: &mut OpenAiResponseRequest) { + request.context_management = None; +} + +struct BedrockMantleModel { + id: LanguageModelId, + model: MantleModel, + http_client: Arc, + state: Entity, + credentials_provider: Arc>, + request_limiter: RateLimiter, +} + +impl BedrockMantleModel { + fn stream_mantle_request( + &self, + request: Request, + cx: &AsyncApp, + endpoint: &'static str, + parse_stream_line: fn(&str) -> Result, + ) -> BoxFuture<'static, Result>, LanguageModelCompletionError>> + where + Request: Serialize + Send + 'static, + Event: Send + 'static, + { + let http_client = self.http_client.clone(); + let model = self.model.clone(); + let credentials_provider = self.credentials_provider.clone(); + let (auth, region) = cx.read_entity(&self.state, |state, _cx| { + (state.auth.clone(), state.get_region()) + }); + let url = format!("{}/{}", mantle_endpoint_url(®ion), endpoint); + let extra_headers = cx.read_entity(&self.state, |_, cx| { + AllLanguageModelSettings::get_global(cx) + .bedrock + .custom_headers + .clone() + }); + let provider_name = PROVIDER_NAME.0.to_string(); + let auth_task = Tokio::spawn_result( + cx, + resolve_mantle_auth(credentials_provider, auth, region.clone()), + ); + + let future = self.request_limiter.stream(async move { + let auth = auth_task + .await + .map_err(LanguageModelCompletionError::Other)?; + stream_mantle_sse( + http_client.as_ref(), + &provider_name, + &url, + ®ion, + &auth, + request, + &extra_headers, + parse_stream_line, + ) + .await + .map_err(|err| map_mantle_error(&model, err)) + }); + + async move { Ok(future.await?.boxed()) }.boxed() + } + + fn stream_completion( + &self, + request: open_ai::Request, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result>, LanguageModelCompletionError>, + > { + self.stream_mantle_request( + request, + cx, + "chat/completions", + parse_mantle_chat_stream_line, + ) + } + + fn stream_response( + &self, + request: OpenAiResponseRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let mut request = request; + strip_unsupported_mantle_response_fields(&mut request); + self.stream_mantle_request(request, cx, "responses", parse_mantle_response_stream_line) + } +} + +impl LanguageModel for BedrockMantleModel { + fn id(&self) -> LanguageModelId { + self.id.clone() + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name().to_string()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + PROVIDER_ID + } + + fn provider_name(&self) -> LanguageModelProviderName { + PROVIDER_NAME + } + + fn supports_tools(&self) -> bool { + self.model.supports_tools() + } + + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { + LanguageModelToolSchemaFormat::JsonSchemaSubset + } + + fn supports_images(&self) -> bool { + self.model.supports_images() + } + + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { + match choice { + LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => { + self.model.supports_tools() + } + LanguageModelToolChoice::None => true, + } + } + + fn supports_streaming_tools(&self) -> bool { + true + } + + fn supports_thinking(&self) -> bool { + self.model.supports_thinking() + } + + fn supported_effort_levels(&self) -> Vec { + mantle_supported_effort_levels(&self.model) + } + + fn supports_split_token_display(&self) -> bool { + true + } + + fn telemetry_id(&self) -> String { + format!("bedrock-mantle/{}", self.model.id()) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn max_output_tokens(&self) -> Option { + Some(self.model.max_output_tokens()) + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let region = cx.read_entity(&self.state, |state, _cx| state.get_region()); + + if !MANTLE_SUPPORTED_REGIONS.contains(®ion.as_str()) { + let display_name = self.model.display_name().to_string(); + let supported = MANTLE_SUPPORTED_REGIONS.join(", "); + return futures::future::ready(Err(LanguageModelCompletionError::Other(anyhow!( + "{display_name} is not available in {region} because Bedrock Mantle isn't offered \ + there. Try switching to one of the following regions: {supported}." + )))) + .boxed(); + } + + let model_id = self.model.request_id().to_string(); + let max_output_tokens = Some(self.model.max_output_tokens()); + + match self.model.protocol() { + MantleProtocol::Responses => { + let request = into_open_ai_response( + request, + &model_id, + self.model.supports_tools(), + false, + max_output_tokens, + mantle_default_reasoning_effort(&self.model), + self.model.supports_thinking(), + ); + let completions = self.stream_response(request, cx); + async move { + let mapper = OpenAiResponseEventMapper::new(); + Ok(mapper.map_stream(completions.await?).boxed()) + } + .boxed() + } + MantleProtocol::ChatCompletions => { + let reasoning_effort = mantle_selected_reasoning_effort(&request, &self.model); + let request = match into_open_ai( + request, + &model_id, + self.model.supports_tools(), + false, + max_output_tokens, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + reasoning_effort, + false, + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; + let completions = self.stream_completion(request, cx); + async move { + let mapper = OpenAiEventMapper::new(); + Ok(mapper.map_stream(completions.await?).boxed()) + } + .boxed() + } + } + } +} + fn deny_tool_use_events( events: impl Stream>, ) -> impl Stream> { @@ -857,6 +1536,10 @@ pub fn into_bedrock( guardrail_identifier: Option, guardrail_version: Option, ) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("Bedrock does not support custom tools"); + } + let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); @@ -884,7 +1567,7 @@ pub fn into_bedrock( } MessageContent::Compaction(_) => None, MessageContent::Thinking { text, signature } => { - if model.contains(Model::DeepSeekR1.request_id()) { + if model.contains(ConverseModel::DeepSeekR1.request_id()) { // DeepSeekR1 doesn't support thinking blocks // And the AWS API demands that you strip them return None; @@ -907,7 +1590,7 @@ pub fn into_bedrock( )) } MessageContent::RedactedThinking(blob) => { - if model.contains(Model::DeepSeekR1.request_id()) { + if model.contains(ConverseModel::DeepSeekR1.request_id()) { // DeepSeekR1 doesn't support thinking blocks // And the AWS API demands that you strip them return None; @@ -919,12 +1602,19 @@ pub fn into_bedrock( } MessageContent::ToolUse(tool_use) => { messages_contain_tool_content = true; - let input = if tool_use.input.is_null() { - // Bedrock API requires valid JsonValue, not null, for tool use input - value_to_aws_document(&serde_json::json!({})) - } else { - value_to_aws_document(&tool_use.input) - }; + let input = + if let language_model::LanguageModelToolUseInput::Json(input) = + &tool_use.input + { + if input.is_null() { + // Bedrock API requires valid JsonValue, not null, for tool use input + value_to_aws_document(&serde_json::json!({})) + } else { + value_to_aws_document(input) + } + } else { + value_to_aws_document(&serde_json::json!({})) + }; BedrockToolUseBlock::builder() .name(tool_use.name.to_string()) .tool_use_id(tool_use.id.to_string()) @@ -1014,7 +1704,7 @@ pub fn into_bedrock( } }) .collect(); - if message.cache && supports_caching { + if message.cache && supports_caching && !bedrock_message_content.is_empty() { bedrock_message_content.push(BedrockInnerContent::CachePoint( CachePointBlock::builder() .r#type(CachePointType::Default) @@ -1058,19 +1748,25 @@ pub fn into_bedrock( request .tools .iter() - .filter_map(|tool| { - Some(BedrockTool::ToolSpec( + .map(|tool| { + let language_model::LanguageModelRequestToolInput::Function { + input_schema, .. + } = &tool.input + else { + anyhow::bail!("Bedrock does not support custom tools"); + }; + Ok(BedrockTool::ToolSpec( BedrockToolSpec::builder() .name(tool.name.clone()) .description(tool.description.clone()) .input_schema(BedrockToolInputSchema::Json(value_to_aws_document( - &tool.input_schema, + input_schema, ))) .build() - .log_err()?, + .context("failed to build Bedrock tool spec")?, )) }) - .collect() + .collect::>()? } else { Vec::new() }; @@ -1225,7 +1921,10 @@ pub fn map_to_language_model_completion_events( name: tool_use.name.clone().into(), is_input_complete: false, raw_input: tool_use.input_json.clone(), - input, + input: + language_model::LanguageModelToolUseInput::Json( + input, + ), thought_signature: None, }, ))) @@ -1290,7 +1989,9 @@ pub fn map_to_language_model_completion_events( name: tool_use.name.into(), is_input_complete: true, raw_input: tool_use.input_json, - input, + input: language_model::LanguageModelToolUseInput::Json( + input, + ), thought_signature: None, }, )) @@ -1492,10 +2193,6 @@ impl ConfigurationView { .detach_and_log_err(cx); } - fn should_render_editor(&self, cx: &Context) -> bool { - self.state.read(cx).is_authenticated() - } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { window.focus_next(cx); } @@ -1577,13 +2274,15 @@ impl Render for ConfigurationView { None }; - if self.should_render_editor(cx) { - return ConfiguredApiCard::new(configured_label) + let credentials_control = if self.state.read(cx).is_authenticated() { + ConfiguredApiCard::new("bedrock-reset", configured_label) .disabled(env_var_set || is_settings_derived) .on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx))) .when_some(tooltip_label, |this, label| this.tooltip_label(label)) - .into_any_element(); - } + .into_any_element() + } else { + self.render_static_credentials_ui().into_any_element() + }; v_flex() .min_w_0() @@ -1592,15 +2291,29 @@ impl Render for ConfigurationView { .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) .on_action(cx.listener(ConfigurationView::save_credentials)) - .child(Label::new("To use Zed's agent with Bedrock, you can set a custom authentication strategy through your settings file or use static credentials.")) - .child(Label::new("But first, to access models on AWS, you need to:").mt_1()) + .gap_1() + .child(Headline::new("Amazon Bedrock").size(HeadlineSize::Small)) + .child( + Label::new( + "To use Zed's agent with Bedrock, you can set a custom authentication strategy through your settings file or use static credentials.", + ) + .color(Color::Muted), + ) + .child( + Label::new("But first, to access models on AWS, you need to:") + .mt_1() + .color(Color::Muted), + ) .child( List::new() .child( ListBulletItem::new("") - .child(Label::new( - "Grant permissions to the strategy you'll use according to the:", - )) + .child( + Label::new( + "Grant permissions to the strategy you'll use according to the:", + ) + .color(Color::Muted), + ) .child(ButtonLink::new( "Prerequisites", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html", @@ -1608,33 +2321,32 @@ impl Render for ConfigurationView { ) .child( ListBulletItem::new("") - .child(Label::new("Select the models you would like access to:")) + .child( + Label::new("Select the models you would like access to:") + .color(Color::Muted), + ) .child(ButtonLink::new( "Bedrock Model Catalog", "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/model-catalog", )), ), ) - .child(self.render_static_credentials_ui()) + .child(credentials_control) .into_any() } } impl ConfigurationView { fn render_static_credentials_ui(&self) -> impl IntoElement { - let section_header = |title: SharedString| { - h_flex() - .gap_2() - .child(Label::new(title).size(LabelSize::Default)) - .child(Divider::horizontal()) - }; - let list_item = List::new() .child( ListBulletItem::new("") - .child(Label::new( - "For access keys: Create an IAM user in the AWS console with programmatic access", - )) + .child( + Label::new( + "For access keys: Create an IAM user in the AWS console with programmatic access", + ) + .color(Color::Muted), + ) .child(ButtonLink::new( "IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users", @@ -1642,7 +2354,10 @@ impl ConfigurationView { ) .child( ListBulletItem::new("") - .child(Label::new("For Bedrock API Keys: Generate an API key from the")) + .child( + Label::new("For Bedrock API Keys: Generate an API key from the") + .color(Color::Muted), + ) .child(ButtonLink::new( "Bedrock Console", "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html", @@ -1650,28 +2365,42 @@ impl ConfigurationView { ) .child( ListBulletItem::new("") - .child(Label::new("Attach the necessary Bedrock permissions to")) + .child( + Label::new("Attach the necessary Bedrock permissions to") + .color(Color::Muted), + ) .child(ButtonLink::new( "this user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html", )), ) - .child(ListBulletItem::new( - "Enter either access keys OR a Bedrock API Key below (not both)", - )); + .child( + ListBulletItem::new( + "Enter either access keys OR a Bedrock API Key below (not both)", + ) + .label_color(Color::Muted), + ); v_flex() .my_2() .tab_group() .gap_1p5() - .child(section_header("Static Credentials".into())) - .child(Label::new( - "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.", - )) + .child(Divider::horizontal()) + .child(Label::new("Static Credentials").mt_2()) + .child( + Label::new( + "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.", + ) + .color(Color::Muted), + ) .child(list_item) - .child(self.access_key_id_editor.clone()) - .child(self.secret_access_key_editor.clone()) - .child(self.session_token_editor.clone()) + .child( + v_flex() + .gap_1() + .child(self.access_key_id_editor.clone()) + .child(self.secret_access_key_editor.clone()) + .child(self.session_token_editor.clone()), + ) .child( Label::new(format!( "You can also set the {}, {} and {} environment variables (or {} for Bedrock API Key authentication) and restart Zed.", @@ -1695,7 +2424,8 @@ impl ConfigurationView { .mt_1() .mb_2p5(), ) - .child(section_header("Using the an API key".into())) + .child(Divider::horizontal()) + .child(Label::new("Using the API key").mt_2().mb_1()) .child(self.bearer_token_editor.clone()) .child( Label::new(format!( @@ -1707,3 +2437,320 @@ impl ConfigurationView { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use language_model::LanguageModelRequestMessage; + + fn into_bedrock_request(messages: Vec) -> bedrock::Request { + into_bedrock( + LanguageModelRequest { + messages, + ..Default::default() + }, + "claude-sonnet-4-5".to_string(), + 1.0, + 4096, + BedrockModelMode::Default, + true, + true, + None, + None, + ) + .unwrap() + } + + #[test] + fn test_cache_marked_message_that_filters_to_empty_is_dropped() { + let request = into_bedrock_request(vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("What's the weather?".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::Thinking { + text: "Let me think about this...".into(), + signature: None, + }], + cache: true, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Summarize this conversation.".into())], + cache: false, + reasoning_details: None, + }, + ]); + + for message in &request.messages { + assert!( + message + .content() + .iter() + .any(|block| !matches!(block, BedrockInnerContent::CachePoint(_))), + "message must not consist solely of cache points: {:?}", + message + ); + } + assert!( + request + .messages + .iter() + .all(|message| *message.role() == bedrock::BedrockRole::User), + "the assistant message stripped to empty content should be dropped entirely" + ); + } + + #[test] + fn test_cache_marked_message_with_content_gets_cache_point() { + let request = into_bedrock_request(vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("What's the weather?".into())], + cache: true, + reasoning_details: None, + }]); + + assert_eq!(request.messages.len(), 1); + assert!( + matches!( + request.messages[0].content().last(), + Some(BedrockInnerContent::CachePoint(_)) + ), + "a cache-marked message with content should end with a cache point" + ); + } + + #[test] + fn test_sign_mantle_request_sigv4_uses_mantle_service() { + let credentials = Credentials::new( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ); + let body = br#"{"model":"openai.gpt-5.5"}"#; + let mut request = HttpRequest::builder() + .method(Method::POST) + .uri("https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses") + .header("Content-Type", "application/json") + .body(AsyncBody::from(body.to_vec())) + .unwrap(); + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + + sign_mantle_request_sigv4_at(&mut request, body, &credentials, "us-east-1", time).unwrap(); + + assert_eq!( + request + .headers() + .get(http_client::http::header::HOST) + .and_then(|value| value.to_str().ok()), + Some("bedrock-mantle.us-east-1.api.aws") + ); + assert_eq!( + request + .headers() + .get("x-amz-date") + .and_then(|value| value.to_str().ok()), + Some("20231114T221320Z") + ); + let authorization = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap(); + assert!(authorization.starts_with("AWS4-HMAC-SHA256 ")); + assert!( + authorization + .contains("Credential=AKIDEXAMPLE/20231114/us-east-1/bedrock-mantle/aws4_request") + ); + assert!(authorization.contains("SignedHeaders=content-type;host")); + assert!(authorization.contains("Signature=")); + } + + #[test] + fn test_mantle_endpoint_url_uses_openai_path_prefix() { + assert_eq!( + mantle_endpoint_url("us-east-1"), + "https://bedrock-mantle.us-east-1.api.aws/openai/v1" + ); + assert_eq!( + mantle_endpoint_url("us-west-2"), + "https://bedrock-mantle.us-west-2.api.aws/openai/v1" + ); + } + + #[test] + fn test_mantle_protocol_from_settings() { + assert_eq!( + mantle_protocol_from_settings(settings::BedrockMantleProtocolContent::ChatCompletions), + MantleProtocol::ChatCompletions + ); + assert_eq!( + mantle_protocol_from_settings(settings::BedrockMantleProtocolContent::Responses), + MantleProtocol::Responses + ); + } + + #[test] + fn test_mantle_supported_regions_matches_docs() { + assert!(MANTLE_SUPPORTED_REGIONS.contains(&"us-east-1")); + assert!(MANTLE_SUPPORTED_REGIONS.contains(&"eu-west-1")); + assert!(!MANTLE_SUPPORTED_REGIONS.contains(&"ap-southeast-1")); + } + + #[test] + fn test_builtin_mantle_models_support_thinking() { + assert!(MantleModel::Gpt5_6Sol.supports_thinking()); + assert!(MantleModel::Gpt5_6Terra.supports_thinking()); + assert!(MantleModel::Gpt5_6Luna.supports_thinking()); + assert!(MantleModel::Gpt5_5.supports_thinking()); + assert!(MantleModel::Gpt5_4.supports_thinking()); + assert!(MantleModel::Grok4_3.supports_thinking()); + assert_eq!( + mantle_default_reasoning_effort(&MantleModel::Gpt5_6Sol), + Some(ReasoningEffort::Medium) + ); + assert_eq!( + mantle_default_reasoning_effort(&MantleModel::Gpt5_6Terra), + Some(ReasoningEffort::Medium) + ); + assert_eq!( + mantle_default_reasoning_effort(&MantleModel::Gpt5_6Luna), + Some(ReasoningEffort::Medium) + ); + assert_eq!( + mantle_default_reasoning_effort(&MantleModel::Gpt5_5), + Some(ReasoningEffort::Medium) + ); + assert_eq!( + mantle_default_reasoning_effort(&MantleModel::Grok4_3), + Some(ReasoningEffort::Medium) + ); + } + + #[test] + fn test_mantle_supported_effort_levels_hide_none() { + let effort_levels = mantle_supported_effort_levels(&MantleModel::Gpt5_5); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + + assert_eq!(values, ["low", "medium", "high", "xhigh"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("medium") + ); + } + + #[test] + fn test_custom_mantle_model_can_disable_thinking() { + let model = MantleModel::Custom { + name: "custom-mantle-model".to_string(), + display_name: None, + max_tokens: 128_000, + max_output_tokens: None, + protocol: MantleProtocol::Responses, + supports_tools: true, + supports_images: false, + supports_thinking: false, + }; + + assert!(!model.supports_thinking()); + assert_eq!(mantle_default_reasoning_effort(&model), None); + assert!(mantle_supported_effort_levels(&model).is_empty()); + assert_eq!( + mantle_selected_reasoning_effort( + &LanguageModelRequest { + thinking_effort: Some("high".to_string()), + ..Default::default() + }, + &model, + ), + None + ); + } + + #[test] + fn test_disabled_mantle_thinking_serializes_none() { + let request = into_open_ai_response( + LanguageModelRequest { + thinking_allowed: false, + ..Default::default() + }, + MantleModel::Grok4_3.request_id(), + true, + false, + Some(MantleModel::Grok4_3.max_output_tokens()), + mantle_default_reasoning_effort(&MantleModel::Grok4_3), + MantleModel::Grok4_3.supports_thinking(), + ); + + assert_eq!( + serde_json::to_value(&request).unwrap()["reasoning"], + serde_json::json!({ "effort": "none" }) + ); + } + + #[test] + fn test_mantle_reasoning_passes_known_efforts_through() { + for effort in ["low", "medium", "high", "xhigh", "minimal", "max"] { + assert_eq!( + mantle_selected_reasoning_effort( + &LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some(effort.to_string()), + ..Default::default() + }, + &MantleModel::Gpt5_5, + ) + .map(|effort| effort.value()), + Some(effort) + ); + } + + assert_eq!( + mantle_selected_reasoning_effort( + &LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("none".to_string()), + ..Default::default() + }, + &MantleModel::Gpt5_5, + ), + Some(ReasoningEffort::Medium) + ); + } + + #[test] + fn test_strip_unsupported_mantle_response_fields_removes_context_management() { + let mut request = into_open_ai_response( + LanguageModelRequest { + compact_at_tokens: Some(10_000), + ..Default::default() + }, + "openai.gpt-5.5", + true, + false, + Some(128_000), + Some(ReasoningEffort::Medium), + false, + ); + + assert!(request.context_management.is_some()); + strip_unsupported_mantle_response_fields(&mut request); + assert!(request.context_management.is_none()); + + let request = serde_json::to_value(&request).unwrap(); + assert!(request.get("context_management").is_none()); + } +} diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index cdd4bb6c8aae0e..06131314eef347 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -9,11 +9,12 @@ use cloud_api_types::Plan; use futures::FutureExt; use futures::StreamExt; use futures::future::BoxFuture; -use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt}; +use gpui::{AnyElement, App, AppContext, Context, Entity, Subscription, Task, TaskExt}; use language_model::{ - AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - ProviderConfigurationView, ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, + AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, ProviderSettingsView, ZED_CLOUD_PROVIDER_ID, + ZED_CLOUD_PROVIDER_NAME, }; use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider}; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; @@ -359,30 +360,48 @@ impl LanguageModelProvider for CloudLanguageModelProvider { }) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|_| ConfigurationView::new(self.state.clone())) - .into() - } + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + let user_store = state.user_store.read(cx); + let is_zed_model_provider_enabled = user_store + .current_organization_configuration() + .map_or(true, |config| config.is_zed_model_provider_enabled); + let description = InlineDescription::Text( + zed_ai_description( + !state.is_signed_out(cx), + user_store.plan(), + is_zed_model_provider_enabled, + user_store.trial_started_at().is_none(), + ) + .into(), + ); - fn configuration_view_v2( - &self, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - // The Zed sign-in/plan control is small enough that sending users to a - // dedicated sub-page just to reach it would be annoying, so render it - // inline even though it isn't an API-key field. - ProviderConfigurationView::Inline(self.configuration_view(target_agent, window, cx)) - } + let title = if state.is_signed_out(cx) { + None + } else { + match state.user_store.read(cx).plan() { + Some(Plan::ZedPro) => Some("Subscribed to Pro".into()), + Some(Plan::ZedProTrial) => Some("Subscribed to Pro Trial".into()), + Some(Plan::ZedStudent) => Some("Subscribed to Student".into()), + Some(Plan::ZedBusiness) => Some("Subscribed to Business".into()), + Some(Plan::ZedVip) => Some("Subscribed to VIP".into()), + Some(Plan::ZedFree) | None => None, + } + }; - fn reset_credentials(&self, _cx: &mut App) -> Task> { - Task::ready(Ok(())) + Some(ProviderSettingsView::Inline( + language_model::InlineProviderSettings { + title, + description: Some(description), + create_view: Arc::new({ + let state = self.state.clone(); + move |_window, cx| { + cx.new(|_| ConfigurationView::new(state.clone(), true)) + .into() + } + }), + }, + )) } fn authentication_error_message(&self) -> SharedString { @@ -413,63 +432,86 @@ struct ZedAiConfiguration { is_zed_model_provider_enabled: bool, eligible_for_trial: bool, account_too_young: bool, + compact: bool, sign_in_callback: Arc, } +fn zed_ai_description( + is_connected: bool, + plan: Option, + is_zed_model_provider_enabled: bool, + eligible_for_trial: bool, +) -> &'static str { + if !is_connected { + return "Sign in to have access to Zed's complete agentic experience with hosted models."; + } + + match plan { + Some(Plan::ZedPro) => { + "You have access to Zed's hosted models through your Pro subscription." + } + Some(Plan::ZedProTrial) => "You have access to Zed's hosted models through your Pro trial.", + Some(Plan::ZedStudent) => { + "You have access to Zed's hosted models through your Student subscription." + } + Some(Plan::ZedBusiness) => { + if is_zed_model_provider_enabled { + "You have access to Zed's hosted models through your organization." + } else { + "Zed's hosted models are disabled by your organization's configuration." + } + } + Some(Plan::ZedVip) => { + "You have access to Zed's hosted models through your VIP subscription." + } + Some(Plan::ZedFree) | None => { + if eligible_for_trial { + "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." + } else { + "Subscribe for access to Zed's hosted models." + } + } + } +} + impl RenderOnce for ZedAiConfiguration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let (subscription_text, has_paid_plan) = match self.plan { - Some(Plan::ZedPro) => ( - "You have access to Zed's hosted models through your Pro subscription.", - true, - ), - Some(Plan::ZedProTrial) => ( - "You have access to Zed's hosted models through your Pro trial.", - false, - ), - Some(Plan::ZedStudent) => ( - "You have access to Zed's hosted models through your Student subscription.", - true, - ), - Some(Plan::ZedBusiness) => ( - if self.is_zed_model_provider_enabled { - "You have access to Zed's hosted models through your organization." - } else { - "Zed's hosted models are disabled by your organization's configuration." - }, - true, - ), - Some(Plan::ZedVip) => ( - "You have access to Zed's hosted models through your VIP subscription.", - true, - ), + let has_paid_plan = matches!( + self.plan, + Some(Plan::ZedPro | Plan::ZedStudent | Plan::ZedBusiness | Plan::ZedVip) + ); - Some(Plan::ZedFree) | None => ( - if self.eligible_for_trial { - "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." - } else { - "Subscribe for access to Zed's hosted models." - }, - false, - ), - }; + let description = zed_ai_description( + self.is_connected, + self.plan, + self.is_zed_model_provider_enabled, + self.eligible_for_trial, + ); let manage_subscription_buttons = if has_paid_plan { Button::new("manage_settings", "Manage Subscription") - .full_width() - .label_size(LabelSize::Small) + .when(!self.compact, |this| { + this.full_width().label_size(LabelSize::Small) + }) + .when(self.compact, |this| this.size(ButtonSize::Medium)) .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) .into_any_element() } else if self.plan.is_none() || self.eligible_for_trial { Button::new("start_trial", "Start 14-day Free Pro Trial") - .full_width() + .when(!self.compact, |this| { + this.full_width().label_size(LabelSize::Small) + }) + .when(self.compact, |this| this.size(ButtonSize::Medium)) .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx))) .into_any_element() } else { Button::new("upgrade", "Upgrade to Pro") - .full_width() + .when(!self.compact, |this| { + this.full_width().label_size(LabelSize::Small) + }) + .when(self.compact, |this| this.size(ButtonSize::Medium)) .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))) .into_any_element() @@ -478,11 +520,15 @@ impl RenderOnce for ZedAiConfiguration { if !self.is_connected { return v_flex() .gap_2() - .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models.")) + .when(!self.compact, |this| this.child(Label::new(description))) .child( Button::new("sign_in", "Sign In to use Zed AI") - .start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted)) - .full_width() + .start_icon( + Icon::new(IconName::Github) + .size(IconSize::Small) + .color(Color::Muted), + ) + .when(!self.compact, |this| this.full_width()) .on_click({ let callback = self.sign_in_callback.clone(); move |_, window, cx| (callback)(window, cx) @@ -490,30 +536,35 @@ impl RenderOnce for ZedAiConfiguration { ); } - v_flex().gap_2().w_full().map(|this| { - if self.account_too_young { - this.child(YoungAccountBanner).child( - Button::new("upgrade", "Upgrade to Pro") - .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) - .full_width() - .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))), - ) - } else { - this.text_sm() - .child(subscription_text) - .child(manage_subscription_buttons) - } - }) + v_flex() + .gap_2() + .when(!self.compact, |this| this.w_full()) + .map(|this| { + if self.account_too_young { + this.child(YoungAccountBanner).child( + Button::new("upgrade", "Upgrade to Pro") + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) + .when(!self.compact, |this| this.full_width()) + .on_click(|_, _, cx| { + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) + }), + ) + } else { + this.when(!self.compact, |this| this.text_sm().child(description)) + .child(manage_subscription_buttons) + } + }) } } struct ConfigurationView { state: Entity, + compact: bool, sign_in_callback: Arc, } impl ConfigurationView { - fn new(state: Entity) -> Self { + fn new(state: Entity, compact: bool) -> Self { let sign_in_callback = Arc::new({ let state = state.clone(); move |_window: &mut Window, cx: &mut App| { @@ -525,6 +576,7 @@ impl ConfigurationView { Self { state, + compact, sign_in_callback, } } @@ -545,6 +597,7 @@ impl Render for ConfigurationView { is_zed_model_provider_enabled, eligible_for_trial: user_store.trial_started_at().is_none(), account_too_young: user_store.account_too_young(), + compact: self.compact, sign_in_callback: self.sign_in_callback.clone(), } } @@ -904,6 +957,7 @@ impl Component for ZedAiConfiguration { is_zed_model_provider_enabled: config.is_zed_model_provider_enabled, eligible_for_trial: config.eligible_for_trial, account_too_young: false, + compact: false, sign_in_callback: Arc::new(|_, _| {}), } .into_any_element() diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 0d3b38d31155aa..4074fab35c2453 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -16,7 +16,7 @@ use copilot_chat::{ use futures::future::BoxFuture; use futures::stream::BoxStream; use futures::{FutureExt, Stream, StreamExt}; -use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task}; +use gpui::{App, AsyncApp, Entity, Subscription, Task}; use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ @@ -25,7 +25,7 @@ use language_model::{ LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, - LanguageModelToolUse, MessageContent, ProviderConfigurationView, RateLimiter, Role, StopReason, + LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, }; use settings::SettingsStore; @@ -177,41 +177,42 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { Task::ready(Err(err.into())) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| { - copilot_ui::ConfigurationView::new( - |cx| { - CopilotChat::global(cx) - .map(|m| m.read(cx).is_authenticated()) - .unwrap_or(false) - }, - copilot_ui::ConfigurationMode::Chat, - cx, - ) - }) - .into() - } - - fn configuration_view_v2( - &self, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - // GitHub Copilot's control is just a sign-in button, so render it inline - // rather than behind a sub-page. - ProviderConfigurationView::Inline(self.configuration_view(target_agent, window, cx)) - } + fn settings_view(&self, cx: &mut App) -> Option { + let is_authenticated = self.state.read(cx).is_authenticated(cx); + let title = if is_authenticated { + None + } else { + Some("Configure Copilot".into()) + }; + let description = if is_authenticated { + None + } else { + Some(language_model::InlineDescription::Text( + "Requires an active GitHub Copilot subscription.".into(), + )) + }; - fn reset_credentials(&self, _cx: &mut App) -> Task> { - Task::ready(Err(anyhow!( - "Signing out of GitHub Copilot Chat is currently not supported." - ))) + Some(ProviderSettingsView::Inline( + language_model::InlineProviderSettings { + title, + description, + create_view: Arc::new(|_window, cx| { + cx.new(|cx| { + copilot_ui::ConfigurationView::new( + |cx| { + CopilotChat::global(cx) + .map(|m| m.read(cx).is_authenticated()) + .unwrap_or(false) + }, + copilot_ui::ConfigurationMode::Chat, + cx, + ) + .compact() + }) + .into() + }), + }, + )) } } @@ -367,7 +368,7 @@ impl LanguageModel for CopilotChatLanguageModel { AnthropicModelMode::Default }, AnthropicPromptCacheMode::Legacy, - ); + )?; anthropic_request.temperature = None; @@ -422,7 +423,10 @@ impl LanguageModel for CopilotChatLanguageModel { if self.model.supports_response() { let location = intent_to_chat_location(request.intent); - let responses_request = into_copilot_responses(&self.model, request); + let responses_request = match into_copilot_responses(&self.model, request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { let request = CopilotChat::stream_response( @@ -566,7 +570,9 @@ pub fn map_to_language_model_completion_events( id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json( + input, + ), raw_input: entry.arguments.clone(), thought_signature: entry.thought_signature.clone(), }, @@ -628,7 +634,10 @@ pub fn map_to_language_model_completion_events( id: tool_call.id.into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: + language_model::LanguageModelToolUseInput::Json( + input, + ), raw_input: tool_call.arguments, thought_signature: tool_call.thought_signature, }, @@ -733,7 +742,7 @@ impl CopilotResponsesEventMapper { id: call_id.into(), name: name.as_str().into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: arguments.clone(), thought_signature, }, @@ -1053,12 +1062,15 @@ fn into_copilot_chat( let mut tool_calls = Vec::new(); for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow!("Copilot Chat does not support custom tool calls") + })?; tool_calls.push(ToolCall { id: tool_use.id.to_string(), content: ToolCallContent::Function { function: FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input)?, + arguments: serde_json::to_string(input)?, thought_signature: tool_use.thought_signature.clone(), }, }, @@ -1119,14 +1131,21 @@ fn into_copilot_chat( let tools = request .tools .iter() - .map(|tool| Tool::Function { - function: Function { - name: tool.name.clone(), - description: tool.description.clone(), - parameters: tool.input_schema.clone(), - }, + .map(|tool| match &tool.input { + language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(Tool::Function { + function: Function { + name: tool.name.clone(), + description: tool.description.clone(), + parameters: input_schema.clone(), + }, + }) + } + language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!( + "Copilot Chat does not support custom tools" + )), }) - .collect::>(); + .collect::>>()?; Ok(CopilotChatRequest { n: 1, @@ -1187,7 +1206,7 @@ fn intent_to_chat_location(intent: Option) -> ChatLocation { fn into_copilot_responses( model: &CopilotChatModel, request: LanguageModelRequest, -) -> copilot_responses::Request { +) -> Result { use copilot_responses as responses; let LanguageModelRequest { @@ -1355,13 +1374,20 @@ fn into_copilot_responses( let converted_tools: Vec = tools .into_iter() - .map(|tool| responses::ToolDefinition::Function { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - strict: None, + .map(|tool| match tool.input { + language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(responses::ToolDefinition::Function { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + strict: None, + }) + } + language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!( + "Copilot Chat does not support custom tools" + )), }) - .collect(); + .collect::>()?; let mapped_tool_choice = tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => responses::ToolChoice::Auto, @@ -1369,7 +1395,7 @@ fn into_copilot_responses( LanguageModelToolChoice::None => responses::ToolChoice::None, }); - responses::Request { + Ok(responses::Request { model: model.id().to_string(), input: input_items, stream: model.uses_streaming(), @@ -1392,7 +1418,7 @@ fn into_copilot_responses( copilot_responses::ResponseIncludable::ReasoningEncryptedContent, ]), store: false, - } + }) } #[cfg(test)] @@ -1670,7 +1696,7 @@ mod tests { ..Default::default() }; - let serialized = serde_json::to_value(into_copilot_responses(&model, request)) + let serialized = serde_json::to_value(into_copilot_responses(&model, request).unwrap()) .expect("serialized request"); let input = serialized["input"].as_array().expect("input items"); diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 48096f168490aa..129ea1bd4515d8 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -5,24 +5,22 @@ use deepseek::DEEPSEEK_API_URL; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - ProviderConfigurationView, RateLimiter, Role, StopReason, TokenUsage, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, + ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var, }; pub use settings::DeepseekAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::util::{fix_streamed_json, parse_tool_arguments}; @@ -197,43 +195,19 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://platform.deepseek.com/api_keys".into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://platform.deepseek.com/api_keys", - "Paste your DeepSeek API key", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -364,7 +338,10 @@ impl LanguageModel for DeepSeekLanguageModel { LanguageModelCompletionError, >, > { - let request = into_deepseek(request, &self.model, self.max_output_tokens()); + let request = match into_deepseek(request, &self.model, self.max_output_tokens()) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_completion(request, cx); async move { @@ -379,7 +356,11 @@ pub fn into_deepseek( request: LanguageModelRequest, model: &deepseek::Model, max_output_tokens: Option, -) -> deepseek::Request { +) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("DeepSeek does not support custom tools"); + } + let thinking = deepseek_thinking(model, request.thinking_allowed); let thinking_enabled = thinking .as_ref() @@ -418,13 +399,16 @@ pub fn into_deepseek( MessageContent::Image(_) => {} MessageContent::Compaction(_) => {} MessageContent::ToolUse(tool_use) => { + let input = tool_use + .input + .as_json() + .ok_or_else(|| anyhow!("DeepSeek does not support custom tool calls"))?; let tool_call = deepseek::ToolCall { id: tool_use.id.to_string(), content: deepseek::ToolCallContent::Function { function: deepseek::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -467,7 +451,7 @@ pub fn into_deepseek( } } - deepseek::Request { + Ok(deepseek::Request { model: model.id().to_string(), messages, stream: true, @@ -492,15 +476,26 @@ pub fn into_deepseek( tools: request .tools .into_iter() - .map(|tool| deepseek::ToolDefinition::Function { - function: deepseek::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("DeepSeek does not support custom tools")); + } + }; + Ok(deepseek::ToolDefinition::Function { + function: deepseek::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), - } + .collect::>()?, + }) } fn deepseek_thinking( @@ -604,7 +599,7 @@ impl DeepSeekEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -635,7 +630,7 @@ impl DeepSeekEventMapper { id: tool_call.id.clone().into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments.clone(), thought_signature: None, }, @@ -661,129 +656,3 @@ impl DeepSeekEventMapper { events } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = - cx.new(|cx| InputField::new(window, cx, "sk-00000000000000000000000000000000")); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn({ - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - let _ = task.await; - } - - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - let state = self.state.clone(); - cx.spawn(async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn(async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = DeepSeekLanguageModelProvider::api_url(cx); - if api_url == DEEPSEEK_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use DeepSeek in Zed, you need an API key:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Get your API key from the")) - .child(ButtonLink::new( - "DeepSeek console", - "https://platform.deepseek.com/api_keys", - )), - ) - .child(ListBulletItem::new( - "Paste your API key below and hit enter to start using the assistant", - )), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 7ea54a51ba3306..e930405c1181fb 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -4,17 +4,17 @@ use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use google_ai::GenerateContentResponse; pub use google_ai::completion::{GoogleEventMapper, into_google}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError, + ApiKeyConfiguration, AuthenticateError, EnvVar, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, - ProviderConfigurationView, }; use language_model::{ GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + ProviderSettingsView, RateLimiter, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -22,9 +22,7 @@ pub use settings::GoogleAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::ApiKeyState; @@ -222,43 +220,19 @@ impl LanguageModelProvider for GoogleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://aistudio.google.com/app/apikey".into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://aistudio.google.com/app/apikey", - "AIza...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -384,11 +358,14 @@ impl LanguageModel for GoogleLanguageModel { LanguageModelCompletionError, >, > { - let request = into_google( + let request = match into_google( request, self.model.request_id().to_string(), self.model.mode(), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let request = self.stream_completion(request, cx); let future = self.request_limiter.stream(async move { let response = request.await.map_err(LanguageModelCompletionError::from)?; @@ -397,142 +374,3 @@ impl LanguageModel for GoogleLanguageModel { async move { Ok(future.await?.boxed()) }.boxed() } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - target_agent: language_model::ConfigurationViewTargetAgent, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new( - state: Entity, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut Context, - ) -> Self { - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor: cx.new(|cx| InputField::new(window, cx, "AIzaSy...")), - target_agent, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!( - "API key set in {} environment variable", - API_KEY_ENV_VAR.name - ) - } else { - let api_url = GoogleLanguageModelProvider::api_url(cx); - if api_url == google_ai::API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { - ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(), - ConfigurationViewTargetAgent::Other(agent) => agent.clone(), - }))) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey")) - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ) - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {GEMINI_API_KEY_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset.")) - }) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs index 921a2ae63e530f..297c1e4284c3dd 100644 --- a/crates/language_models/src/provider/llama_cpp.rs +++ b/crates/language_models/src/provider/llama_cpp.rs @@ -4,15 +4,16 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task, TaskExt}; +use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::util::parse_tool_arguments; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView, + RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage, env_var, }; use llama_cpp::{ LLAMA_CPP_API_URL, ModelEntry, Props, get_models, get_props, stream_chat_completion, @@ -25,8 +26,7 @@ use std::sync::LazyLock; use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; use std::time::Duration; use ui::{ - ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, - prelude::*, + ButtonLike, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*, }; use ui_input::InputField; use util::ResultExt; @@ -597,20 +597,17 @@ impl LanguageModelProvider for LlamaCppLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { + fn settings_view(&self, _cx: &mut App) -> Option { let state = self.state.clone(); - cx.new(|cx| ConfigurationView::new(state, window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "Run local models on your machine with LlamaCpp.".into(), + )), + )) } } @@ -653,7 +650,7 @@ impl LlamaCppLanguageModel { fn to_llama_cpp_request( &self, request: LanguageModelRequest, - ) -> llama_cpp::ChatCompletionRequest { + ) -> Result { build_llama_cpp_request( &self.name, self.supports_images, @@ -700,7 +697,11 @@ fn build_llama_cpp_request( supports_images: bool, capabilities: LiveCapabilities, request: LanguageModelRequest, -) -> llama_cpp::ChatCompletionRequest { +) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("llama.cpp does not support custom tools"); + } + let supports_tools = capabilities.supports_tools; let supports_thinking = capabilities.supports_thinking; let mut messages = Vec::new(); @@ -746,13 +747,15 @@ fn build_llama_cpp_request( } } MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow::anyhow!("llama.cpp does not support custom tool calls") + })?; let tool_call = llama_cpp::ToolCall { id: tool_use.id.to_string(), content: llama_cpp::ToolCallContent::Function { function: llama_cpp::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -814,14 +817,25 @@ fn build_llama_cpp_request( request .tools .into_iter() - .map(|tool| llama_cpp::ToolDefinition::Function { - function: llama_cpp::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("llama.cpp does not support custom tools")); + } + }; + Ok(llama_cpp::ToolDefinition::Function { + function: llama_cpp::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect() + .collect::>()? } else { Vec::new() }; @@ -837,7 +851,7 @@ fn build_llama_cpp_request( }) }; - llama_cpp::ChatCompletionRequest { + Ok(llama_cpp::ChatCompletionRequest { model: model_name.to_string(), messages, stream: true, @@ -856,7 +870,7 @@ fn build_llama_cpp_request( stream_options: Some(llama_cpp::StreamOptions { include_usage: true, }), - } + }) } impl LanguageModel for LlamaCppLanguageModel { @@ -922,7 +936,10 @@ impl LanguageModel for LlamaCppLanguageModel { LanguageModelCompletionError, >, > { - let request = self.to_llama_cpp_request(request); + let request = match self.to_llama_cpp_request(request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = LlamaCppEventMapper::new(); @@ -1022,7 +1039,9 @@ impl LlamaCppEventMapper { id: tool_call.id.into(), name: tool_call.name.into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json( + input, + ), raw_input: tool_call.arguments, thought_signature: None, }, @@ -1327,31 +1346,42 @@ impl ConfigurationView { fn render_instructions(cx: &App) -> Div { v_flex() .gap_2() - .child(Label::new( - "Run open models locally with llama.cpp's built-in server, or connect to a \ + .child( + Label::new( + "Run open models locally with llama.cpp's built-in server, or connect to a \ remote llama.cpp server.", - )) - .child(Label::new("To use a local llama.cpp server:")) + ) + .color(Color::Muted), + ) + .child(Label::new("To use a local llama.cpp server:").color(Color::Muted)) .child( List::new() .child( ListBulletItem::new("") - .child(Label::new("Install llama.cpp from")) + .child(Label::new("Install llama.cpp from").color(Color::Muted)) .child(ButtonLink::new("llama.app", LLAMA_CPP_DOWNLOAD_URL)), ) .child( ListBulletItem::new("") - .child(Label::new("Start the server in router mode:")) + .child( + Label::new("Start the server in router mode:").color(Color::Muted), + ) .child(Label::new("llama serve").inline_code(cx)), ) - .child(ListBulletItem::new( - "Click 'Connect' below to start using llama.cpp in Zed", - )), + .child( + ListBulletItem::new( + "Click 'Connect' below to start using llama.cpp in Zed", + ) + .label_color(Color::Muted), + ), ) - .child(Label::new( - "Alternatively, you can connect to a remote llama.cpp server by specifying its \ + .child( + Label::new( + "Alternatively, you can connect to a remote llama.cpp server by specifying its \ URL and API key (set with --api-key, may not be required):", - )) + ) + .color(Color::Muted), + ) } fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement { @@ -1363,20 +1393,10 @@ impl ConfigurationView { "API key configured".to_string() }; - if !state.api_key_state.has_key() { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let api_key_control = if !state.api_key_state.has_key() { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("llama-cpp-reset-key", configured_card_label) .disabled(env_var_set) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) .when(env_var_set, |this| { @@ -1385,7 +1405,20 @@ impl ConfigurationView { )) }) .into_any_element() - } + }; + + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(api_key_control) + .gap_1p5() + .mb_2() + .child( + Label::new(format!( + "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) } fn render_context_window_editor(&self, cx: &Context) -> Div { @@ -1394,26 +1427,26 @@ impl ConfigurationView { if custom_context_window_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(format!( + .child(Label::new(format!( "Context Window: {}", settings.context_window.unwrap_or_default() - )))), + ))), ) .child( Button::new("reset-context-window", "Reset") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| { this.reset_context_window(window, cx) @@ -1428,8 +1461,9 @@ impl ConfigurationView { }), ) .child(self.context_window_editor.clone()) + .gap_1p5() .child( - Label::new("Default: discovered from the server") + Label::new("Default: Discovered from the server") .size(LabelSize::Small) .color(Color::Muted), ) @@ -1442,23 +1476,23 @@ impl ConfigurationView { if custom_api_url_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(api_url))), + .child(Label::new(api_url)), ) .child( Button::new("reset-api-url", "Reset API URL") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)), ), @@ -1469,7 +1503,7 @@ impl ConfigurationView { this.save_api_url(cx); cx.notify(); })) - .gap_2() + .gap_1p5() .child(self.api_url_editor.clone()) } } @@ -1481,12 +1515,15 @@ impl Render for ConfigurationView { v_flex() .gap_2() + .child(Headline::new("llama.cpp").size(HeadlineSize::Small)) .child(Self::render_instructions(cx)) .child(self.render_api_url_editor(cx)) .child(self.render_context_window_editor(cx)) .child(self.render_api_key_editor(cx)) + .child(Divider::horizontal()) .child( h_flex() + .pt_2() .w_full() .justify_between() .gap_2() @@ -1498,7 +1535,8 @@ impl Render for ConfigurationView { if is_authenticated { this.child( Button::new("llama-cpp-webui", "Open WebUI") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1513,7 +1551,8 @@ impl Render for ConfigurationView { ) .child( Button::new("llama-cpp-site", "llama.cpp") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1527,7 +1566,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("download_llama_cpp_button", "Get llama.cpp") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1542,7 +1582,8 @@ impl Render for ConfigurationView { }) .child( Button::new("view-models", "Browse GGUF Models") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1555,17 +1596,16 @@ impl Render for ConfigurationView { if is_authenticated { this.child( ButtonLike::new("connected") - .disabled(true) - .cursor_style(CursorStyle::Arrow) + .size(ButtonSize::Medium) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new("Connected")) - .into_any_element(), + .child(Label::new("Connected")), ) .child( IconButton::new("refresh-models", IconName::RotateCcw) + .icon_size(IconSize::Small) .tooltip(Tooltip::text("Refresh Models")) .on_click(cx.listener(|this, _, window, cx| { this.state.update(cx, |state, _| { @@ -1578,6 +1618,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_llama_cpp_models", "Connect") + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .start_icon( Icon::new(IconName::PlayOutlined).size(IconSize::XSmall), ) @@ -1809,7 +1851,8 @@ mod tests { }], ..Default::default() }, - ); + ) + .unwrap(); assert_eq!(request.messages.len(), 1); match &request.messages[0] { @@ -1852,7 +1895,8 @@ mod tests { }], ..Default::default() }, - ); + ) + .unwrap(); assert_eq!(request.messages.len(), 1); match &request.messages[0] { @@ -1862,7 +1906,7 @@ mod tests { tool_calls, } => { assert_eq!(content, "answer"); - assert_eq!(reasoning_content, &None); + assert!(reasoning_content.is_none()); assert!(tool_calls.is_empty()); } message => panic!("unexpected message: {message:?}"), @@ -1891,7 +1935,9 @@ mod tests { id: "call_1".into(), name: "weather".into(), raw_input: r#"{"city":"Oslo"}"#.to_string(), - input: serde_json::json!({ "city": "Oslo" }), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::json!({ "city": "Oslo" }), + ), is_input_complete: true, thought_signature: None, }), @@ -1901,7 +1947,8 @@ mod tests { }], ..Default::default() }, - ); + ) + .unwrap(); assert_eq!(request.messages.len(), 1); match &request.messages[0] { diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index b7b076125647c9..518b4a26155021 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -3,7 +3,7 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Subscription, Task, TaskExt}; +use gpui::{App, AsyncApp, Context, Entity, Subscription, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, @@ -11,8 +11,9 @@ use language_model::{ LanguageModelToolUse, MessageContent, StopReason, TokenUsage, env_var, }; use language_model::{ - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, Role, + InlineDescription, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, ProviderSettingsView, RateLimiter, Role, SubPageProviderSettings, }; use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models}; @@ -24,9 +25,7 @@ use std::{ collections::{BTreeMap, HashMap}, sync::Arc, }; -use ui::{ - ButtonLike, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, prelude::*, -}; +use ui::{ButtonLike, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*}; use ui_input::InputField; use crate::AllLanguageModelSettings; @@ -313,19 +312,17 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), _window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "Run local LLMs like Llama, Phi, and Qwen with LM Studio.".into(), + )), + )) } } @@ -341,7 +338,11 @@ impl LmStudioLanguageModel { fn to_lmstudio_request( &self, request: LanguageModelRequest, - ) -> lmstudio::ChatCompletionRequest { + ) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("LM Studio does not support custom tools"); + } + let mut messages = Vec::new(); for message in request.messages { @@ -368,13 +369,15 @@ impl LmStudioLanguageModel { ); } MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow!("LM Studio does not support custom tool calls") + })?; let tool_call = lmstudio::ToolCall { id: tool_use.id.to_string(), content: lmstudio::ToolCallContent::Function { function: lmstudio::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -420,10 +423,13 @@ impl LmStudioLanguageModel { } } - lmstudio::ChatCompletionRequest { + Ok(lmstudio::ChatCompletionRequest { model: self.model.name.clone(), messages, stream: true, + stream_options: Some(lmstudio::StreamOptions { + include_usage: true, + }), max_tokens: Some(-1), stop: Some(request.stop), // In LM Studio you can configure specific settings you'd like to use for your model. @@ -433,20 +439,31 @@ impl LmStudioLanguageModel { tools: request .tools .into_iter() - .map(|tool| lmstudio::ToolDefinition::Function { - function: lmstudio::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("LM Studio does not support custom tools")); + } + }; + Ok(lmstudio::ToolDefinition::Function { + function: lmstudio::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), + .collect::>()?, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => lmstudio::ToolChoice::Auto, LanguageModelToolChoice::Any => lmstudio::ToolChoice::Required, LanguageModelToolChoice::None => lmstudio::ToolChoice::None, }), - } + }) } fn stream_completion( @@ -536,7 +553,10 @@ impl LanguageModel for LmStudioLanguageModel { LanguageModelCompletionError, >, > { - let request = self.to_lmstudio_request(request); + let request = match self.to_lmstudio_request(request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = LmStudioEventMapper::new(); @@ -574,13 +594,23 @@ impl LmStudioEventMapper { &mut self, event: lmstudio::ResponseStreamEvent, ) -> Vec> { + let mut events = Vec::new(); + + if let Some(usage) = event.usage { + events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: usage.prompt_tokens, + output_tokens: usage.completion_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }))); + } + + // The final usage summary chunk from OpenAI-compatible servers has an empty choices array. + // Return accumulated events instead of treating it as an error. let Some(choice) = event.choices.into_iter().next() else { - return vec![Err(LanguageModelCompletionError::from(anyhow!( - "Response contained no choices" - )))]; + return events; }; - let mut events = Vec::new(); if let Some(content) = choice.delta.content { events.push(Ok(LanguageModelCompletionEvent::Text(content))); } @@ -619,15 +649,6 @@ impl LmStudioEventMapper { } } - if let Some(usage) = event.usage { - events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }))); - } - match choice.finish_reason.as_deref() { Some("stop") => { events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); @@ -640,7 +661,7 @@ impl LmStudioEventMapper { id: tool_call.id.into(), name: tool_call.name.into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments, thought_signature: None, }, @@ -674,6 +695,142 @@ struct RawToolCall { arguments: String, } +#[cfg(test)] +mod tests { + use super::*; + use lmstudio::{ChoiceDelta, ResponseMessageDelta, ResponseStreamEvent, Usage}; + + fn make_event(choices: Vec, usage: Option) -> ResponseStreamEvent { + ResponseStreamEvent { + created: 0, + model: "test-model".to_string(), + object: "chat.completion.chunk".to_string(), + choices, + usage, + } + } + + fn make_content_choice(content: &str) -> ChoiceDelta { + ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: Some(content.to_string()), + reasoning_content: None, + tool_calls: None, + }, + finish_reason: None, + } + } + + fn make_stop_choice() -> ChoiceDelta { + ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: None, + reasoning_content: None, + tool_calls: None, + }, + finish_reason: Some("stop".to_string()), + } + } + + // OpenAI-compatible servers send a final chunk with usage data and an empty + // choices array. Before this fix, the mapper returned an error for empty + // choices, discarding usage entirely. + #[test] + fn test_usage_in_final_empty_choices_chunk() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event( + vec![], + Some(Usage { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }), + ); + + let results: Vec<_> = mapper + .map_event(event) + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + assert_eq!( + results, + vec![LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + })] + ); + } + + #[test] + fn test_empty_choices_without_usage_returns_empty() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event(vec![], None); + + let results = mapper.map_event(event); + + assert!(results.is_empty()); + } + + // Usage data can also arrive in a regular chunk that also contains content. + // Both events must be emitted, with UsageUpdate first. + #[test] + fn test_usage_emitted_alongside_content() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event( + vec![make_content_choice("Hello!")], + Some(Usage { + prompt_tokens: 5, + completion_tokens: 3, + total_tokens: 8, + }), + ); + + let results: Vec<_> = mapper + .map_event(event) + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + assert_eq!( + results[0], + LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 5, + output_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }) + ); + assert_eq!( + results[1], + LanguageModelCompletionEvent::Text("Hello!".to_string()) + ); + } + + #[test] + fn test_stop_event_emitted_on_finish_reason() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event(vec![make_stop_choice()], None); + + let results: Vec<_> = mapper + .map_event(event) + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + assert_eq!( + results, + vec![LanguageModelCompletionEvent::Stop(StopReason::EndTurn)] + ); + } +} + fn add_message_content_part( new_part: lmstudio::MessagePart, role: Role, @@ -833,28 +990,8 @@ impl ConfigurationView { let custom_api_url_set = api_url != LMSTUDIO_API_URL; if custom_api_url_set { - h_flex() - .p_3() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) - .child( - h_flex() - .gap_2() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(api_url))), - ) - .child( - Button::new("reset-api-url", "Reset API URL") - .label_size(LabelSize::Small) - .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) - .on_click( - cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)), - ), - ) + ConfiguredApiCard::new("reset-api-url", api_url) + .on_click(cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx))) .into_any_element() } else { v_flex() @@ -862,7 +999,6 @@ impl ConfigurationView { this.save_api_url(cx); cx.notify(); })) - .gap_2() .child(self.api_url_editor.clone()) .into_any_element() } @@ -877,20 +1013,10 @@ impl ConfigurationView { "API key configured".to_string() }; - if !state.api_key_state.has_key() { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let api_key_control = if !state.api_key_state.has_key() { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("lmstudio-reset-key", configured_card_label) .disabled(env_var_set) .on_click(cx.listener(|this, _, _window, cx| this.reset_api_key(_window, cx))) .when(env_var_set, |this| { @@ -899,7 +1025,20 @@ impl ConfigurationView { )) }) .into_any_element() - } + }; + + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(api_key_control) + .gap_1p5() + .mb_2() + .child( + Label::new(format!( + "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) } } @@ -912,39 +1051,45 @@ impl Render for ConfigurationView { .child( v_flex() .gap_1() - .child(Label::new("Run local LLMs like Llama, Phi, and Qwen.")) + .child(Headline::new("LM Studio").size(HeadlineSize::Small)) + .child( + Label::new("Run local LLMs like Llama, Phi, and Qwen.").color(Color::Muted), + ) .child( List::new() .child(ListBulletItem::new( "LM Studio needs to be running with at least one model downloaded.", - )) + ).label_color(Color::Muted)) .child( ListBulletItem::new("") - .child(Label::new("To get your first model, try running")) - .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)), + .child(Label::new("To get your first model, try running").color(Color::Muted)) + .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx).color(Color::Muted).ml_1()), ), ) .child(Label::new( "Alternatively, you can connect to an LM Studio server by specifying its \ URL and API key (may not be required):", - )), + ).color(Color::Muted)), ) .child(self.render_api_url_editor(cx)) .child(self.render_api_key_editor(cx)) + .child(Divider::horizontal()) .child( h_flex() + .pt_2() .w_full() .justify_between() - .gap_2() + .gap_1() .child( h_flex() .w_full() - .gap_2() + .gap_1() .map(|this| { if is_authenticated { this.child( Button::new("lmstudio-site", "LM Studio") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::Small) @@ -961,7 +1106,8 @@ impl Render for ConfigurationView { "download_lmstudio_button", "Download LM Studio", ) - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::Small) @@ -976,7 +1122,8 @@ impl Render for ConfigurationView { }) .child( Button::new("view-models", "Model Catalog") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::Small) @@ -991,18 +1138,17 @@ impl Render for ConfigurationView { if is_authenticated { this.child( ButtonLike::new("connected") - .disabled(true) - .cursor_style(CursorStyle::Arrow) + .size(ButtonSize::Medium) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new("Connected")) - .into_any_element(), ) .child( IconButton::new("refresh-models", IconName::RotateCcw) .tooltip(Tooltip::text("Refresh Models")) + .icon_size(IconSize::Small) .on_click(cx.listener(|this, _, _window, cx| { this.state.update(cx, |state, _| { state.available_models.clear(); @@ -1014,6 +1160,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_lmstudio_models", "Connect") + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .start_icon( Icon::new(IconName::PlayFilled).size(IconSize::XSmall), ) diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 24afcb669b5cbe..a45564b191b81d 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -3,15 +3,15 @@ use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolUse, MessageContent, ProviderConfigurationView, RateLimiter, Role, StopReason, - TokenUsage, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView, + RateLimiter, Role, StopReason, TokenUsage, env_var, }; pub use mistral::{MISTRAL_API_URL, StreamResponse}; pub use settings::MistralAvailableModel as AvailableModel; @@ -19,9 +19,7 @@ use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::util::{fix_streamed_json, parse_tool_arguments}; @@ -222,43 +220,19 @@ impl LanguageModelProvider for MistralLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://console.mistral.ai/api-keys".into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://console.mistral.ai/api-keys", - "Paste your Mistral API key", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -369,7 +343,10 @@ impl LanguageModel for MistralLanguageModel { >, > { let (request, affinity) = - into_mistral(request, self.model.clone(), self.max_output_tokens()); + match into_mistral(request, self.model.clone(), self.max_output_tokens()) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_completion(request, affinity, cx); async move { @@ -385,7 +362,11 @@ pub fn into_mistral( request: LanguageModelRequest, model: mistral::Model, max_output_tokens: Option, -) -> (mistral::Request, Option) { +) -> Result<(mistral::Request, Option)> { + if request.contains_custom_tool_input() { + anyhow::bail!("Mistral does not support custom tools"); + } + let stream = true; let mut messages = Vec::new(); @@ -478,13 +459,15 @@ pub fn into_mistral( MessageContent::Image(_) => {} MessageContent::Compaction(_) => {} MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow!("Mistral does not support custom tool calls") + })?; let tool_call = mistral::ToolCall { id: tool_use.id.to_string(), content: mistral::ToolCallContent::Function { function: mistral::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -542,7 +525,7 @@ pub fn into_mistral( } } - ( + Ok(( mistral::Request { model: model.id().to_string(), messages, @@ -576,17 +559,28 @@ pub fn into_mistral( tools: request .tools .into_iter() - .map(|tool| mistral::ToolDefinition::Function { - function: mistral::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("Mistral does not support custom tools")); + } + }; + Ok(mistral::ToolDefinition::Function { + function: mistral::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), + .collect::>()?, }, request.thread_id, - ) + )) } pub struct MistralEventMapper { @@ -690,7 +684,7 @@ impl MistralEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -747,7 +741,7 @@ impl MistralEventMapper { id: tool_call.id.into(), name: tool_call.name.into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments, thought_signature: None, }, @@ -774,145 +768,6 @@ struct RawToolCall { arguments: String, } -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = - cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_api_key_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = MistralLanguageModelProvider::api_url(cx); - if api_url == MISTRAL_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() - } else if self.should_render_api_key_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys")) - ) - .child( - ListBulletItem::new("Ensure your Mistral account has credits") - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any() - } else { - v_flex() - .size_full() - .gap_1() - .child( - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!( - "To reset your API key, \ - unset the {API_KEY_ENV_VAR_NAME} environment variable." - )) - }), - ) - .into_any() - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -978,7 +833,10 @@ mod tests { assert_eq!(tool_use.id.to_string(), "real_id_123"); assert_eq!(tool_use.name.as_ref(), "read_file"); - assert_eq!(tool_use.input, serde_json::json!({"path": "a.txt"})); + assert_eq!( + tool_use.input, + language_model::LanguageModelToolUseInput::Json(serde_json::json!({"path": "a.txt"})) + ); } #[test] @@ -1019,7 +877,7 @@ mod tests { }; let (mistral_request, affinity) = - into_mistral(request, mistral::Model::MistralSmallLatest, None); + into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap(); assert_eq!(mistral_request.model, "mistral-small-latest"); assert_eq!(mistral_request.temperature, Some(0.5)); @@ -1055,7 +913,8 @@ mod tests { compact_at_tokens: None, }; - let (mistral_request, _) = into_mistral(request, mistral::Model::MistralSmallLatest, None); + let (mistral_request, _) = + into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap(); assert_eq!(mistral_request.messages.len(), 1); assert!(matches!( diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 43a14c33e875f5..9ef157f9655869 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -4,15 +4,16 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use futures::{Stream, TryFutureExt, stream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task, TaskExt}; +use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, InlineDescription, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, + LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, env_var, + ProviderSettingsView, RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage, + env_var, }; use menu; use ollama::{ @@ -25,8 +26,7 @@ use std::pin::Pin; use std::sync::Arc; use std::sync::LazyLock; use ui::{ - ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, - prelude::*, + ButtonLike, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*, }; use ui_input::InputField; @@ -336,20 +336,17 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { + fn settings_view(&self, _cx: &mut App) -> Option { let state = self.state.clone(); - cx.new(|cx| ConfigurationView::new(state, window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "Run local models on your machine with Ollama.".into(), + )), + )) } } @@ -363,7 +360,11 @@ pub struct OllamaLanguageModel { } impl OllamaLanguageModel { - fn to_ollama_request(&self, request: LanguageModelRequest) -> ChatRequest { + fn to_ollama_request(&self, request: LanguageModelRequest) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("Ollama does not support custom tools"); + } + let supports_vision = self.model.supports_vision.unwrap_or(false); let mut messages = Vec::with_capacity(request.messages.len()); @@ -425,7 +426,16 @@ impl OllamaLanguageModel { id: tool_use.id.to_string(), function: OllamaFunctionCall { name: tool_use.name.to_string(), - arguments: tool_use.input, + arguments: match tool_use.input { + language_model::LanguageModelToolUseInput::Json( + input, + ) => input, + language_model::LanguageModelToolUseInput::Text(_) => { + return Err(anyhow::anyhow!( + "Ollama does not support custom tool calls" + )); + } + }, }, }); } @@ -448,7 +458,7 @@ impl OllamaLanguageModel { }), } } - ChatRequest { + Ok(ChatRequest { model: self.model.name.clone(), messages, keep_alive: self.model.keep_alive.clone().unwrap_or_default(), @@ -471,11 +481,15 @@ impl OllamaLanguageModel { .supports_thinking .map(|supports_thinking| supports_thinking && request.thinking_allowed), tools: if self.model.supports_tools.unwrap_or(false) { - request.tools.into_iter().map(tool_into_ollama).collect() + request + .tools + .into_iter() + .map(tool_into_ollama) + .collect::>()? } else { vec![] }, - } + }) } } @@ -539,7 +553,10 @@ impl LanguageModel for OllamaLanguageModel { LanguageModelCompletionError, >, > { - let request = self.to_ollama_request(request); + let request = match self.to_ollama_request(request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let http_client = self.http_client.clone(); let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| { @@ -624,7 +641,9 @@ fn map_to_language_model_completion_events( id: LanguageModelToolUseId::from(id), name: Arc::from(function.name), raw_input: function.arguments.to_string(), - input: function.arguments, + input: language_model::LanguageModelToolUseInput::Json( + function.arguments, + ), is_input_complete: true, thought_signature: None, }); @@ -832,31 +851,43 @@ impl ConfigurationView { fn render_instructions(cx: &App) -> Div { v_flex() .gap_2() - .child(Label::new( - "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \ + .child( + Label::new( + "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \ Can provide access to Llama, Mistral, Gemma, and hundreds of other models.", - )) - .child(Label::new("To use local Ollama:")) + ) + .color(Color::Muted), + ) + .child(Label::new("To use local Ollama:").color(Color::Muted)) .child( List::new() .child( ListBulletItem::new("") - .child(Label::new("Download and install Ollama from")) + .child( + Label::new("Download and install Ollama from").color(Color::Muted), + ) .child(ButtonLink::new("ollama.com", "https://ollama.com/download")), ) .child( ListBulletItem::new("") - .child(Label::new("Start Ollama and download a model:")) + .child( + Label::new("Start Ollama and download a model:") + .color(Color::Muted), + ) .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)), ) - .child(ListBulletItem::new( - "Click 'Connect' below to start using Ollama in Zed", - )), + .child( + ListBulletItem::new("Click 'Connect' below to start using Ollama in Zed") + .label_color(Color::Muted), + ), ) - .child(Label::new( - "Alternatively, you can connect to an Ollama server by specifying its \ + .child( + Label::new( + "Alternatively, you can connect to an Ollama server by specifying its \ URL and API key (may not be required):", - )) + ) + .color(Color::Muted), + ) } fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement { @@ -868,27 +899,30 @@ impl ConfigurationView { "API key configured".to_string() }; - if !state.api_key_state.has_key() { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.") - ) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let api_key_control = if !state.api_key_state.has_key() { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("ollama-reset-key", configured_card_label) .disabled(env_var_set) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) .when(env_var_set, |this| { this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) }) .into_any_element() - } + }; + + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(api_key_control) + .gap_1p5() + .mb_2() + .child( + Label::new( + format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.") + ) + .size(LabelSize::Small) + .color(Color::Muted), + ) } fn render_context_window_editor(&self, cx: &Context) -> Div { @@ -897,26 +931,26 @@ impl ConfigurationView { if custom_context_window_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(format!( + .child(Label::new(format!( "Context Window: {}", settings.context_window.unwrap() - )))), + ))), ) .child( Button::new("reset-context-window", "Reset") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| { this.reset_context_window(window, cx) @@ -931,6 +965,7 @@ impl ConfigurationView { }), ) .child(self.context_window_editor.clone()) + .gap_1p5() .child( Label::new("Default: Model specific") .size(LabelSize::Small) @@ -945,23 +980,23 @@ impl ConfigurationView { if custom_api_url_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(api_url))), + .child(Label::new(api_url)), ) .child( Button::new("reset-api-url", "Reset API URL") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)), ), @@ -984,15 +1019,18 @@ impl Render for ConfigurationView { v_flex() .gap_2() + .child(Headline::new("Ollama").size(HeadlineSize::Small)) .child(Self::render_instructions(cx)) .child(self.render_api_url_editor(cx)) .child(self.render_context_window_editor(cx)) .child(self.render_api_key_editor(cx)) + .child(Divider::horizontal()) .child( h_flex() + .pt_2() .w_full() .justify_between() - .gap_2() + .gap_1() .child( h_flex() .w_full() @@ -1001,7 +1039,8 @@ impl Render for ConfigurationView { if is_authenticated { this.child( Button::new("ollama-site", "Ollama") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1013,7 +1052,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("download_ollama_button", "Download Ollama") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1028,7 +1068,8 @@ impl Render for ConfigurationView { }) .child( Button::new("view-models", "View All Models") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1041,17 +1082,16 @@ impl Render for ConfigurationView { if is_authenticated { this.child( ButtonLike::new("connected") - .disabled(true) - .cursor_style(CursorStyle::Arrow) + .size(ButtonSize::Medium) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new("Connected")) - .into_any_element(), + .child(Label::new("Connected")), ) .child( IconButton::new("refresh-models", IconName::RotateCcw) + .icon_size(IconSize::Small) .tooltip(Tooltip::text("Refresh Models")) .on_click(cx.listener(|this, _, window, cx| { this.state.update(cx, |state, _| { @@ -1064,6 +1104,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_ollama_models", "Connect") + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .start_icon( Icon::new(IconName::PlayOutlined).size(IconSize::XSmall), ) @@ -1110,14 +1152,22 @@ fn merge_settings_into_models( } } -fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool { - ollama::OllamaTool::Function { +fn tool_into_ollama(tool: LanguageModelRequestTool) -> Result { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { + input_schema + } + language_model::LanguageModelRequestToolInput::Custom { .. } => { + anyhow::bail!("Ollama does not support custom tools"); + } + }; + Ok(ollama::OllamaTool::Function { function: OllamaFunctionTool { name: tool.name, description: Some(tool.description), - parameters: Some(tool.input_schema), + parameters: Some(input_schema), }, - } + }) } #[cfg(test)] diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index a67a8741fcb15d..eabb0ed5032958 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -2,28 +2,25 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, ProviderConfigurationView, - RateLimiter, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, + ProviderSettingsView, RateLimiter, env_var, }; -use menu; use open_ai::{ - OPEN_AI_API_URL, ResponseStreamEvent, + ResponseStreamEvent, responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response}, stream_completion, }; use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; pub use open_ai::completion::{ ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, @@ -204,43 +201,19 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://platform.openai.com/api-keys".into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://platform.openai.com/api-keys", - "sk-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } fn fast_mode_confirmation(&self, _cx: &App) -> Option { @@ -491,6 +464,9 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointFourPro | Model::FivePointFive | Model::FivePointFivePro + | Model::FivePointSixSol + | Model::FivePointSixTerra + | Model::FivePointSixLuna | Model::O3 => true, Model::Four => false, Model::Custom { @@ -580,7 +556,7 @@ impl LanguageModel for OpenAiLanguageModel { } .boxed() } else { - let request = into_open_ai( + let request = match into_open_ai( request, self.model.id(), self.model.supports_parallel_tool_calls(), @@ -589,7 +565,10 @@ impl LanguageModel for OpenAiLanguageModel { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = OpenAiEventMapper::new(); @@ -599,183 +578,3 @@ impl LanguageModel for OpenAiLanguageModel { } } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "sk-000000000000000000000000000000000000000000000000", - ) - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenAiLanguageModelProvider::api_url(cx); - if api_url == OPEN_AI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys")) - ) - .child( - ListBulletItem::new("Ensure your OpenAI account has credits") - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new( - "Note that having a subscription for another service like GitHub Copilot won't work.", - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .into_any_element() - }; - - let compatible_api_section = h_flex() - .mt_1p5() - .gap_0p5() - .flex_wrap() - .when(self.should_render_editor(cx), |this| { - this.pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - h_flex() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new("Zed also supports OpenAI-compatible models.")), - ) - .child( - Button::new("docs", "Learn More") - .end_icon( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(move |_, _window, cx| { - cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible") - }), - ); - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials…")).into_any() - } else { - v_flex() - .size_full() - .child(api_key_section) - .child(compatible_api_section) - .into_any() - } - } -} diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 847978b7955bfd..ff67344ac5aba6 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,14 +1,14 @@ use anyhow::Result; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, RateLimiter, + LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, SubPageProviderSettings, }; use open_ai::{ ResponseStreamEvent, @@ -144,27 +144,27 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| { - ApiCompatibleProviderConfigurationView::new( - self.state.clone(), - "OpenAI", - API_KEY_PLACEHOLDER, - window, - cx, - ) - }) - .into() + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage(SubPageProviderSettings::new( + move |window, cx| { + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + state.clone(), + "OpenAI", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + }, + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -424,7 +424,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { if self.model.capabilities.chat_completions { let reasoning_effort = chat_completion_reasoning_effort(&request, &self.model); - let request = into_open_ai( + let request = match into_open_ai( request, &self.model.name, self.model.capabilities.parallel_tool_calls, @@ -433,7 +433,10 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { chat_completion_max_tokens_parameter(&self.model), reasoning_effort, self.model.capabilities.interleaved_reasoning, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = OpenAiEventMapper::new(); @@ -688,7 +691,8 @@ mod tests { chat_completion_max_tokens_parameter(&model), reasoning_effort, model.capabilities.interleaved_reasoning, - ); + ) + .unwrap(); let serialized = serde_json::to_value(request).unwrap(); assert_eq!(serialized["reasoning_effort"], json!("high")); diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 979022a8878ee6..59d4aac3fb7611 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -2,15 +2,15 @@ use anyhow::Result; use collections::HashMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, ProviderConfigurationView, - RateLimiter, Role, StopReason, TokenUsage, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, + MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var, }; use open_router::{ Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models, @@ -18,9 +18,7 @@ use open_router::{ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::util::{fix_streamed_json, parse_tool_arguments}; @@ -259,43 +257,19 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://openrouter.ai/keys".into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://openrouter.ai/keys", - "sk-or-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -426,7 +400,11 @@ impl LanguageModel for OpenRouterLanguageModel { LanguageModelCompletionError, >, > { - let openrouter_request = into_open_router(request, &self.model, self.max_output_tokens()); + let openrouter_request = + match into_open_router(request, &self.model, self.max_output_tokens()) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let request = self.stream_completion(openrouter_request, cx); let future = self.request_limiter.stream(async move { let response = request.await?; @@ -440,7 +418,11 @@ pub fn into_open_router( request: LanguageModelRequest, model: &Model, max_output_tokens: Option, -) -> open_router::Request { +) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("OpenRouter does not support custom tools"); + } + // Anthropic models via OpenRouter don't accept reasoning_details being echoed back // in requests - it's an output-only field for them. However, Gemini models require // the thought signatures to be echoed back for proper reasoning chain continuity. @@ -498,13 +480,15 @@ pub fn into_open_router( message_added_content = true; } MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow::anyhow!("OpenRouter does not support custom tool calls") + })?; let tool_call = open_router::ToolCall { id: tool_use.id.to_string(), content: open_router::ToolCallContent::Function { function: open_router::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), thought_signature: tool_use.thought_signature.clone(), }, }, @@ -577,7 +561,7 @@ pub fn into_open_router( } } - open_router::Request { + Ok(open_router::Request { model: model.id().into(), messages, stream: true, @@ -606,21 +590,32 @@ pub fn into_open_router( tools: request .tools .into_iter() - .map(|tool| open_router::ToolDefinition::Function { - function: open_router::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("OpenRouter does not support custom tools")); + } + }; + Ok(open_router::ToolDefinition::Function { + function: open_router::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), + .collect::>()?, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto, LanguageModelToolChoice::Any => open_router::ToolChoice::Required, LanguageModelToolChoice::None => open_router::ToolChoice::None, }), provider: model.provider.clone(), - } + }) } fn open_router_session_id(thread_id: Option) -> Option { @@ -835,7 +830,7 @@ impl OpenRouterEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: entry.thought_signature.clone(), }, @@ -858,7 +853,7 @@ impl OpenRouterEventMapper { id: tool_call.id.clone().into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments.clone(), thought_signature: tool_call.thought_signature.clone(), }, @@ -895,141 +890,6 @@ struct RawToolCall { thought_signature: Option, } -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "sk_or_000000000000000000000000000000000000000000000000", - ) - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - let _ = task.await; - } - - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenRouterLanguageModelProvider::api_url(cx); - if api_url == OPEN_ROUTER_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create an API key by visiting")) - .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys")) - ) - .child(ListBulletItem::new("Ensure your OpenRouter account has credits") - ) - .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .into_any_element() - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -1275,7 +1135,7 @@ mod tests { ..Default::default() }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); assert_eq!( result.session_id.as_deref(), @@ -1377,7 +1237,7 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); let system_cache = result.messages.iter().find_map(|m| { if let open_router::RequestMessage::System { content } = m { @@ -1516,7 +1376,7 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); for message in &result.messages { let content = match message { @@ -1579,7 +1439,7 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); for message in &result.messages { let content = match message { diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index f63268e60e242a..4e2dee2075cac9 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -3,17 +3,17 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, http::{HeaderName, HeaderValue}, }; use language_model::{ - AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, + AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, RateLimiter, + LanguageModelToolChoice, ProviderSettingsView, RateLimiter, }; use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; @@ -31,6 +31,9 @@ const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("opena const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("ChatGPT Subscription"); +const SUBSCRIPTION_DESCRIPTION: &str = + "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent."; + const CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const OPENAI_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; @@ -154,10 +157,6 @@ impl OpenAiSubscribedProvider { }); } - fn sign_out(&self, cx: &mut App) -> Task> { - do_sign_out(&self.state.downgrade(), cx) - } - fn create_language_model(&self, model: ChatGptModel) -> Arc { Arc::new(OpenAiSubscribedLanguageModel { id: LanguageModelId::from(model.id().to_string()), @@ -195,9 +194,7 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } fn default_fast_model(&self, _cx: &App) -> Option> { - // No GPT-5.5 Mini exists yet; per the OpenAI Codex docs, gpt-5.4-mini - // is the recommended fast/cheap default alongside gpt-5.5. - Some(self.create_language_model(ChatGptModel::Gpt54Mini)) + Some(self.create_language_model(ChatGptModel::Gpt56Luna)) } fn provided_models(&self, _cx: &App) -> Vec> { @@ -240,31 +237,48 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _window: &mut Window, - cx: &mut App, - ) -> AnyView { - let state = self.state.clone(); - let http_client = self.http_client.clone(); - cx.new(|_cx| ConfigurationView { state, http_client }) - .into() - } + fn settings_view(&self, cx: &mut App) -> Option { + let is_authenticated = self.state.read(cx).is_authenticated(); + let title = if is_authenticated { + None + } else { + Some("Configure ChatGPT".into()) + }; + let description = if is_authenticated { + None + } else { + Some(InlineDescription::Text(SUBSCRIPTION_DESCRIPTION.into())) + }; - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.sign_out(cx) + Some(ProviderSettingsView::Inline( + language_model::InlineProviderSettings { + title, + description, + create_view: Arc::new({ + let state = self.state.clone(); + let http_client = self.http_client.clone(); + move |_window, cx| { + cx.new(|_cx| ConfigurationView { + state: state.clone(), + http_client: http_client.clone(), + compact: true, + }) + .into() + } + }), + }, + )) } fn authentication_error_message(&self) -> SharedString { "Your ChatGPT subscription session is invalid or has expired. \ - Sign in again via the Agent Panel settings to continue." + Sign in again via Settings > AI > LLM Providers to continue." .into() } fn missing_credentials_error_message(&self) -> SharedString { "You are not signed in to your ChatGPT account. \ - Sign in via the Agent Panel settings to continue." + Sign in via Settings > AI > LLM Providers to continue." .into() } @@ -295,6 +309,9 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { // approximation; the entries below mirror that file's picker-visible models. #[derive(Clone, Debug, PartialEq)] enum ChatGptModel { + Gpt56Sol, + Gpt56Terra, + Gpt56Luna, Gpt55, Gpt54, Gpt54Mini, @@ -302,11 +319,21 @@ enum ChatGptModel { impl ChatGptModel { fn all() -> Vec { - vec![Self::Gpt55, Self::Gpt54, Self::Gpt54Mini] + vec![ + Self::Gpt56Sol, + Self::Gpt56Terra, + Self::Gpt56Luna, + Self::Gpt55, + Self::Gpt54, + Self::Gpt54Mini, + ] } fn id(&self) -> &str { match self { + Self::Gpt56Sol => "gpt-5.6-sol", + Self::Gpt56Terra => "gpt-5.6-terra", + Self::Gpt56Luna => "gpt-5.6-luna", Self::Gpt55 => "gpt-5.5", Self::Gpt54 => "gpt-5.4", Self::Gpt54Mini => "gpt-5.4-mini", @@ -315,6 +342,9 @@ impl ChatGptModel { fn display_name(&self) -> &str { match self { + Self::Gpt56Sol => "GPT-5.6 Sol", + Self::Gpt56Terra => "GPT-5.6 Terra", + Self::Gpt56Luna => "GPT-5.6 Luna", Self::Gpt55 => "GPT-5.5", Self::Gpt54 => "GPT-5.4", Self::Gpt54Mini => "GPT-5.4 Mini", @@ -322,11 +352,10 @@ impl ChatGptModel { } fn max_token_count(&self) -> u64 { - // All Codex-supported models use a 272K context window in the Codex - // backend, even when the raw model exposes a larger context window via the - // public API (e.g. gpt-5.4 has max_context_window 1M, but Codex uses - // context_window 272K). Source: openai/codex models-manager/models.json. - 272_000 + match self { + Self::Gpt56Sol | Self::Gpt56Terra | Self::Gpt56Luna => 372_000, + Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => 272_000, + } } fn max_output_tokens(&self) -> Option { @@ -340,18 +369,30 @@ impl ChatGptModel { } fn default_reasoning_effort(&self) -> Option { - // Codex bundled models all default to Medium reasoning effort. - Some(ReasoningEffort::Medium) + match self { + Self::Gpt56Sol => Some(ReasoningEffort::Low), + Self::Gpt56Terra | Self::Gpt56Luna | Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => { + Some(ReasoningEffort::Medium) + } + } } fn supported_reasoning_efforts(&self) -> &'static [ReasoningEffort] { - // The Codex backend's supported_reasoning_levels for every model in this list is low/medium/high/xhigh - &[ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::XHigh, - ] + match self { + Self::Gpt56Sol | Self::Gpt56Terra | Self::Gpt56Luna => &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ], + Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ], + } } fn supports_parallel_tool_calls(&self) -> bool { @@ -364,7 +405,7 @@ impl ChatGptModel { fn supports_priority(&self) -> bool { match self { - Self::Gpt55 | Self::Gpt54 => true, + Self::Gpt56Sol | Self::Gpt56Terra | Self::Gpt56Luna | Self::Gpt55 | Self::Gpt54 => true, Self::Gpt54Mini => false, } } @@ -433,7 +474,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { ReasoningEffort::Medium => ("Medium", "medium"), ReasoningEffort::High => ("High", "high"), ReasoningEffort::XHigh => ("Extra High", "xhigh"), - ReasoningEffort::Max => return None, // Not supported by any OpenAI models + ReasoningEffort::Max => ("Max", "max"), }; Some(LanguageModelEffortLevel { @@ -744,10 +785,12 @@ async fn do_oauth_flow( .query_pairs_mut() .append_pair("client_id", CLIENT_ID) .append_pair("redirect_uri", &redirect_uri) - .append_pair( - "scope", - "openid profile email offline_access api.connectors.read api.connectors.invoke", - ) + // Deliberately excludes `api.connectors.read api.connectors.invoke` + // (which Codex CLI requests): extra scopes inflate the + // access-token JWT, and the serialized credentials must fit within + // Windows Credential Manager's 2560-byte blob limit + // (CRED_MAX_CREDENTIAL_BLOB_SIZE). See #58541. + .append_pair("scope", "openid profile email offline_access") .append_pair("response_type", "code") .append_pair("code_challenge", &challenge) .append_pair("code_challenge_method", "S256") @@ -1043,6 +1086,9 @@ fn do_sign_out(state: &gpui::WeakEntity, cx: &mut App) -> Task struct ConfigurationView { state: Entity, http_client: Arc, + /// When `true`, the description is rendered elsewhere (the settings row's + /// left column), so it's omitted here to avoid duplication. + compact: bool, } impl Render for ConfigurationView { @@ -1059,7 +1105,7 @@ impl Render for ConfigurationView { return v_flex() .child( - ConfiguredApiCard::new(SharedString::from(label)) + ConfiguredApiCard::new("openai-subscribed-sign-out", SharedString::from(label)) .button_label("Sign Out") .on_click(cx.listener(move |_this, _, _window, cx| { do_sign_out(&weak_state, cx).detach_and_log_err(cx); @@ -1076,27 +1122,21 @@ impl Render for ConfigurationView { let button_label = if is_signing_in { "Signing in…" } else { - "Sign in to use ChatGPT Subscription" + "Sign In" }; v_flex() .gap_2() - .child(Label::new( - "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent.", - )) + .when(!self.compact, |this| { + this.child(Label::new(SUBSCRIPTION_DESCRIPTION)) + }) .child( Button::new("sign-in", button_label) - .full_width() + .when(!self.compact, |this| this.full_width()) .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .loading(is_signing_in) .disabled(is_signing_in) - .when(!is_signing_in, |this| { - this.start_icon( - Icon::new(IconName::AiOpenAi) - .size(IconSize::Small) - .color(Color::Muted), - ) - }) .on_click(move |_, _window, cx| { do_sign_in(&provider_state, &http_client, cx); }), diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 9a5dbcdfccb40a..6604995ffee14a 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -3,22 +3,24 @@ use collections::BTreeMap; use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{AsyncBody, CustomHeaders, HttpClient, http}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, - ReasoningEffort, env_var, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, ProviderSettingsView, RateLimiter, ReasoningEffort, + SubPageProviderSettings, env_var, }; use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription}; +pub use settings::OpenCodeApiProtocol; pub use settings::OpenCodeAvailableModel as AvailableModel; use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ - Banner, ButtonLink, ConfiguredApiCard, List, ListBulletItem, Severity, Switch, + Banner, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Severity, Switch, SwitchLabelPosition, ToggleState, prelude::*, }; use ui_input::InputField; @@ -262,12 +264,12 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { } for model in &settings.available_models { - let protocol = match model.protocol.as_str() { - "anthropic" => ApiProtocol::Anthropic, - "openai_responses" => ApiProtocol::OpenAiResponses, - "openai_chat" => ApiProtocol::OpenAiChat, - "google" => ApiProtocol::Google, - _ => ApiProtocol::OpenAiChat, // default fallback + let protocol = match model.protocol { + Some(OpenCodeApiProtocol::Anthropic) => ApiProtocol::Anthropic, + Some(OpenCodeApiProtocol::OpenAiResponses) => ApiProtocol::OpenAiResponses, + Some(OpenCodeApiProtocol::OpenAiChat) => ApiProtocol::OpenAiChat, + Some(OpenCodeApiProtocol::Google) => ApiProtocol::Google, + None => ApiProtocol::OpenAiChat, // default fallback }; let subscription = match model.subscription { Some(settings::OpenCodeModelSubscription::Go) => OpenCodeSubscription::Go, @@ -305,19 +307,17 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "To use OpenCode models in Zed, you need an API key.".into(), + )), + )) } } @@ -575,6 +575,12 @@ impl LanguageModel for OpenCodeLanguageModel { .is_some_and(|levels| levels.iter().any(|effort| *effort != ReasoningEffort::None)) } + fn supports_disabling_thinking(&self) -> bool { + self.model + .supported_reasoning_effort_levels() + .is_some_and(|levels| levels.contains(&ReasoningEffort::None)) + } + fn supported_effort_levels(&self) -> Vec { self.model .supported_reasoning_effort_levels() @@ -663,7 +669,7 @@ impl LanguageModel for OpenCodeLanguageModel { } else { anthropic::AnthropicModelMode::Default }; - let anthropic_request = into_anthropic( + let anthropic_request = match into_anthropic( request, self.model.id().to_string(), 1.0, @@ -672,7 +678,10 @@ impl LanguageModel for OpenCodeLanguageModel { .unwrap_or(8192), mode, anthropic::completion::AnthropicPromptCacheMode::Automatic, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_anthropic(anthropic_request, http_client, extra_headers, cx); async move { @@ -690,16 +699,19 @@ impl LanguageModel for OpenCodeLanguageModel { } else { None }; - let openai_request = into_open_ai( + let openai_request = match into_open_ai( request, self.model.id(), - false, + true, false, self.model.max_output_tokens(self.subscription), ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, self.model.interleaved_reasoning(), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_openai_chat(openai_request, http_client, extra_headers, cx); async move { @@ -716,7 +728,7 @@ impl LanguageModel for OpenCodeLanguageModel { let response_request = into_open_ai_response( request, self.model.id(), - false, + true, false, self.model.max_output_tokens(self.subscription), None, @@ -731,11 +743,17 @@ impl LanguageModel for OpenCodeLanguageModel { .boxed() } ApiProtocol::Google => { - let google_request = into_google( - request, - self.model.id().to_string(), - google_ai::GoogleModelMode::Default, - ); + let mode = if self.supports_thinking() && request.thinking_allowed { + google_ai::GoogleModelMode::Thinking { + budget_tokens: None, + } + } else { + google_ai::GoogleModelMode::Default + }; + let google_request = match into_google(request, self.model.id().to_string(), mode) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_google(google_request, http_client, extra_headers, cx); async move { let mapper = GoogleEventMapper::new(); @@ -859,37 +877,12 @@ impl Render for ConfigurationView { } }; - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new( - "To use OpenCode models in Zed, you need an API key:", - )) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Sign in and get your key at")) - .child(ButtonLink::new( - "OpenCode Console", - "https://opencode.ai/auth", - )), - ) - .child(ListBulletItem::new( - "Paste your API key below and hit enter to start using OpenCode", - )), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let is_editing = self.should_render_editor(cx); + + let api_key_control = if is_editing { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("opencode-reset-key", configured_card_label) .disabled(env_var_set) .when(env_var_set, |this| { this.tooltip_label(format!( @@ -900,8 +893,39 @@ impl Render for ConfigurationView { .into_any_element() }; + let api_key_section = v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(Label::new( + "To use OpenCode models in Zed, you need an API key:", + ).color(Color::Muted)) + .child( + List::new() + .child( + ListBulletItem::new("") + .child(Label::new("Sign in and get your key at").color(Color::Muted)) + .child(ButtonLink::new( + "OpenCode Console", + "https://opencode.ai/auth", + )), + ) + .when(is_editing, |this| { + this.child(ListBulletItem::new( + "Paste your API key below and hit enter to start using OpenCode", + ).label_color(Color::Muted)) + }), + ) + .child(api_key_control) + .child( + Label::new(format!( + "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted).mt_1p5(), + ) + .into_any_element(); + if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() + Label::new("Loading Credentials…").into_any_element() } else { let settings = OpenCodeLanguageModelProvider::settings(cx); let show_zen = settings.show_zen_models; @@ -909,12 +933,13 @@ impl Render for ConfigurationView { let show_free = settings.show_free_models; let subscription_toggles = v_flex() - .gap_1() - .child(Label::new("Subscriptions:").color(Color::Muted)) + .gap_2() + .child(Label::new("Subscriptions")) .child( Switch::new("opencode-show-zen-models", show_zen.into()) - .label("Show Zen models") - .label_position(SwitchLabelPosition::End) + .full_width(true) + .label("Show Zen Models") + .label_position(SwitchLabelPosition::Start) .on_click(cx.listener(|this, state, window, cx| { this.set_subscription_enabled( OpenCodeSubscription::Zen, @@ -924,10 +949,12 @@ impl Render for ConfigurationView { ); })), ) + .child(Divider::horizontal_dashed()) .child( Switch::new("opencode-show-go-models", show_go.into()) + .full_width(true) .label("Show Go models") - .label_position(SwitchLabelPosition::End) + .label_position(SwitchLabelPosition::Start) .on_click(cx.listener(|this, state, window, cx| { this.set_subscription_enabled( OpenCodeSubscription::Go, @@ -937,10 +964,12 @@ impl Render for ConfigurationView { ); })), ) + .child(Divider::horizontal_dashed()) .child( Switch::new("opencode-show-free-models", show_free.into()) + .full_width(true) .label("Show Free models") - .label_position(SwitchLabelPosition::End) + .label_position(SwitchLabelPosition::Start) .on_click(cx.listener(|this, state, window, cx| { this.set_subscription_enabled( OpenCodeSubscription::Free, @@ -961,8 +990,10 @@ impl Render for ConfigurationView { v_flex() .size_full() - .gap_2() + .gap_2p5() + .child(Headline::new("OpenCode").size(HeadlineSize::Small)) .child(api_key_section) + .child(Divider::horizontal()) .child(subscription_toggles) .children(no_subscriptions_warning) .into_any() diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index d319538bc431c3..23868b937479c1 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -2,16 +2,16 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http, }; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, - ProviderConfigurationView, RateLimiter, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, env_var, }; use open_ai::ResponseStreamEvent; use serde::Deserialize; @@ -19,9 +19,7 @@ pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; pub use settings::VercelAiGatewayAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel_ai_gateway"); const PROVIDER_NAME: LanguageModelProviderName = @@ -246,43 +244,20 @@ impl LanguageModelProvider for VercelAiGatewayLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway" + .into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway", - "Paste your Vercel AI Gateway API key", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -476,7 +451,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel { LanguageModelCompletionError, >, > { - let request = crate::provider::open_ai::into_open_ai( + let request = match crate::provider::open_ai::into_open_ai( request, &self.model.name, self.model.capabilities.parallel_tool_calls, @@ -485,7 +460,10 @@ impl LanguageModel for VercelAiGatewayLanguageModel { crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_open_ai(request, cx); async move { let mapper = crate::provider::open_ai::OpenAiEventMapper::new(); @@ -625,131 +603,3 @@ async fn list_models( Ok(models) } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = - cx.new(|cx| InputField::new(window, cx, "vck_000000000000000000000000000")); - - cx.observe(&state, |_, _, cx| cx.notify()).detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx); - if api_url == API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new( - "To use Zed's agent with Vercel AI Gateway, you need to add an API key. Follow these steps:", - )) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create an API key in")) - .child(ButtonLink::new( - "Vercel AI Gateway's console", - "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway", - )), - ) - .child(ListBulletItem::new( - "Paste your API key below and hit enter to start using the assistant", - )), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.", - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 1d21464c5ffbf9..834b97eb8bec03 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -2,23 +2,22 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, ProviderConfigurationView, RateLimiter, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, + env_var, }; use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use x_ai::XAI_API_URL; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); @@ -193,43 +192,19 @@ impl LanguageModelProvider for XAiLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + fn settings_view(&self, cx: &mut App) -> Option { + let state = self.state.read(cx); + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://console.x.ai/team/default/api-keys".into(), + ))) } - fn reset_credentials(&self, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://console.x.ai/team/default/api-keys", - "xai-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -438,7 +413,7 @@ impl LanguageModel for XAiLanguageModel { >, > { let reasoning_effort = reasoning_effort_for_request(&request, &self.model); - let request = crate::provider::open_ai::into_open_ai( + let request = match crate::provider::open_ai::into_open_ai( request, self.model.id(), self.model.supports_parallel_tool_calls(), @@ -447,7 +422,10 @@ impl LanguageModel for XAiLanguageModel { crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, false, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = crate::provider::open_ai::OpenAiEventMapper::new(); @@ -457,87 +435,6 @@ impl LanguageModel for XAiLanguageModel { } } -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "xai-0000000000000000000000000000000000000000000000000", - ) - .label("API key") - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - #[cfg(test)] mod tests { use super::*; @@ -587,64 +484,3 @@ mod tests { ); } } - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = XAiLanguageModelProvider::api_url(cx); - if api_url == XAI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys")) - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("Note that xAI is a custom OpenAI-compatible provider.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .into_any_element() - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials…")).into_any() - } else { - v_flex().size_full().child(api_key_section).into_any() - } - } -} diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 1ce7caa3a675fb..4f360eea95c845 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -95,6 +95,7 @@ impl settings::Settings for AllLanguageModelSettings { .collect(), bedrock: AmazonBedrockSettings { available_models: bedrock.available_models.unwrap_or_default(), + mantle_available_models: bedrock.mantle_available_models.unwrap_or_default(), custom_headers: custom_headers_from( "Amazon Bedrock", bedrock.custom_headers, diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 07be3150c0c456..46060e92c7b21e 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -462,7 +462,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; if enable_thinking && effort.is_some() { request.thinking = Some(anthropic::Thinking::Adaptive { @@ -592,7 +595,7 @@ impl LanguageModel for CloudLanguageModel { let http_client = self.http_client.clone(); let token_provider = self.token_provider.clone(); - let request = into_open_ai( + let request = match into_open_ai( request, &self.model.id.0, self.model.supports_parallel_tool_calls, @@ -601,7 +604,10 @@ impl LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let auth_context = token_provider.auth_context(cx); let future = self.request_limiter.stream(async move { let PerformLlmCompletionResponse { @@ -640,7 +646,11 @@ impl LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let auth_context = token_provider.auth_context(cx); let future = self.request_limiter.stream(async move { let PerformLlmCompletionResponse { diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index e9e6dc82ffc096..4694aa813ddeda 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -53,8 +53,10 @@ impl Render for ActiveBufferLanguage { }; el.child( - Button::new("change-language", active_language_text) + Button::new("change-language", active_language_text.clone()) .label_size(LabelSize::Small) + .tab_index(0isize) + .aria_label(format!("Language: {active_language_text}")) .on_click(cx.listener(|this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { workspace.update(cx, |workspace, cx| { diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 26e230c1d92f67..4e18b4f3ef7413 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -31,7 +31,7 @@ serde_json.workspace = true settings.workspace = true telemetry.workspace = true theme.workspace = true -tree-sitter.workspace = true +tree-sitter = { workspace = true, features = ["wasm"] } sysinfo.workspace = true ui.workspace = true util.workspace = true @@ -45,4 +45,4 @@ gpui = { workspace = true, features = ["test-support"] } semver.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true -theme_settings.workspace = true \ No newline at end of file +theme_settings.workspace = true diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index 1b58e830153ea5..e63f6e3051cedf 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -14,10 +14,9 @@ use std::{mem, ops::Range, sync::Arc, time::Duration}; use theme::ActiveTheme; use theme::SyntaxTheme; use ui::{ - ButtonCommon, ButtonLike, ButtonStyle, Color, ContextMenu, FluentBuilder as _, IconButton, - IconName, IconPosition, IconSize, Label, LabelCommon, LabelSize, PopoverMenu, - PopoverMenuHandle, StyledExt, Toggleable, Tooltip, WithScrollbar, h_flex, v_flex, + ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, WithScrollbar, prelude::*, }; + use workspace::{ Event as WorkspaceEvent, SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -983,9 +982,8 @@ impl HighlightsTreeToolbarItemView { PopoverMenu::new("highlights-tree-settings") .trigger_with_tooltip( - IconButton::new("toggle-highlights-settings-icon", IconName::Sliders) + IconButton::new("toggle-highlights-settings-icon", IconName::Filter) .icon_size(IconSize::Small) - .style(ButtonStyle::Subtle) .toggle_state(self.toggle_settings_handle.is_deployed()), Tooltip::text("Highlights Settings"), ) diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index f038fdf93227f7..d3a15970fc070b 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -723,6 +723,19 @@ impl LanguageServers { fn is_empty(&self) -> bool { self.binary_statuses.is_empty() && self.health_statuses.is_empty() } + + /// Drop all id-keyed state for a server that has been removed (stopped or + /// reaching end-of-life via restart). `binary_statuses` is intentionally + /// preserved — it is keyed by name and shared across restart cycles to + /// drive the "Downloading… → Starting…" status UX. + fn remove_server(&mut self, server_id: LanguageServerId) { + self.health_statuses.remove(&server_id); + self.servers_per_buffer_abs_path + .retain(|_, servers_for_path| { + servers_for_path.servers.remove(&server_id); + !servers_for_path.servers.is_empty() + }); + } } #[derive(Debug)] @@ -902,7 +915,6 @@ impl LspButton { let mut updated = false; // TODO `LspStore` is global and reports status from all language servers, even from the other windows. - // Also, we do not get "LSP removed" events so LSPs are never removed. match e { LspStoreEvent::LanguageServerUpdate { language_server_id, @@ -992,6 +1004,12 @@ impl LspButton { }); updated = true; } + LspStoreEvent::LanguageServerRemoved(server_id) => { + self.server_state.update(cx, |state, _| { + state.language_servers.remove_server(*server_id); + }); + updated = true; + } _ => {} }; @@ -1396,6 +1414,8 @@ impl Render for LspButton { IconButton::new("zed-lsp-tool-button", IconName::BoltOutlined) .when_some(indicator, IconButton::indicator) .icon_size(IconSize::Small) + .tab_index(0isize) + .aria_label("Language Servers") .when(is_restricted, |s| s.icon_color(Color::Warning)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)), move |_window, cx| { @@ -1405,3 +1425,163 @@ impl Render for LspButton { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn server_id(n: usize) -> LanguageServerId { + LanguageServerId(n) + } + + fn server_name(s: &str) -> LanguageServerName { + LanguageServerName(s.into()) + } + + fn health_status(name: &str) -> LanguageServerHealthStatus { + LanguageServerHealthStatus { + name: server_name(name), + health: Some((None, ServerHealth::Ok)), + } + } + + fn servers_for_path(servers: &[(LanguageServerId, &str)]) -> ServersForPath { + ServersForPath { + servers: servers + .iter() + .map(|(id, name)| (*id, Some(server_name(name)))) + .collect(), + worktree: None, + } + } + + /// `remove_server` evicts the id from `health_statuses` so a restarted + /// server's new id renders without inheriting the old one's stale entry. + /// This is the regression test for #53627. + #[test] + fn remove_server_drops_health_entry_for_id() { + let mut state = LanguageServers::default(); + state + .health_statuses + .insert(server_id(1), health_status("rust-analyzer")); + state + .health_statuses + .insert(server_id(2), health_status("typescript-language-server")); + + state.remove_server(server_id(1)); + + assert!(!state.health_statuses.contains_key(&server_id(1))); + assert!(state.health_statuses.contains_key(&server_id(2))); + } + + /// `remove_server` evicts the id from each per-buffer entry; entries that + /// become empty are dropped so the map does not grow unbounded across + /// many buffer opens/closes. + #[test] + fn remove_server_evicts_id_from_per_buffer_entries_and_drops_empty_entries() { + let mut state = LanguageServers::default(); + let buffer_a = PathBuf::from("/project/a.rs"); + let buffer_b = PathBuf::from("/project/b.rs"); + + state.servers_per_buffer_abs_path.insert( + buffer_a.clone(), + servers_for_path(&[(server_id(1), "rust-analyzer")]), + ); + state.servers_per_buffer_abs_path.insert( + buffer_b.clone(), + servers_for_path(&[(server_id(1), "rust-analyzer"), (server_id(2), "typos-lsp")]), + ); + + state.remove_server(server_id(1)); + + assert!( + !state.servers_per_buffer_abs_path.contains_key(&buffer_a), + "buffer_a's entry held only the removed server, so the entry itself should be dropped", + ); + let buffer_b_entry = state + .servers_per_buffer_abs_path + .get(&buffer_b) + .expect("buffer_b's entry has another server, so it must be retained"); + assert!(!buffer_b_entry.servers.contains_key(&server_id(1))); + assert!(buffer_b_entry.servers.contains_key(&server_id(2))); + } + + /// `binary_statuses` is keyed by name and intentionally shared across + /// restart cycles to drive the "Downloading… → Starting…" UX. Removing a + /// single server's id must not touch it. + #[test] + fn remove_server_does_not_touch_binary_statuses() { + let mut state = LanguageServers::default(); + state.binary_statuses.insert( + server_name("rust-analyzer"), + LanguageServerBinaryStatus { + status: BinaryStatus::Starting, + message: None, + }, + ); + + state.remove_server(server_id(1)); + + assert!( + state + .binary_statuses + .contains_key(&server_name("rust-analyzer")), + "binary_statuses is name-keyed and shared across restart cycles", + ); + } + + /// Simulates the full restart event sequence: remove old id, register + /// new id with same name, write health for the new id. After restart + /// only the new id should be visible — no leftover entry from the old + /// incarnation. + #[test] + fn restart_sequence_leaves_only_new_server_id() { + let mut state = LanguageServers::default(); + let buffer = PathBuf::from("/project/main.rs"); + let name = "rust-analyzer"; + + // Pre-restart: server v1 is registered for the buffer with health. + state + .servers_per_buffer_abs_path + .insert(buffer.clone(), servers_for_path(&[(server_id(1), name)])); + state + .health_statuses + .insert(server_id(1), health_status(name)); + + // Restart: old id is removed. + state.remove_server(server_id(1)); + + // New id registers for the same buffer. + let entry = state + .servers_per_buffer_abs_path + .entry(buffer.clone()) + .or_insert_with(|| ServersForPath { + servers: HashMap::default(), + worktree: None, + }); + entry.servers.insert(server_id(2), Some(server_name(name))); + + // Health update for the new id arrives. + state + .health_statuses + .insert(server_id(2), health_status(name)); + + let entry = state + .servers_per_buffer_abs_path + .get(&buffer) + .expect("buffer must still be tracked"); + assert_eq!( + entry.servers.keys().copied().collect::>(), + vec![server_id(2)], + "exactly one server for this buffer — the new incarnation", + ); + assert!( + !state.health_statuses.contains_key(&server_id(1)), + "the dead server's health entry must not linger", + ); + assert!( + state.health_statuses.contains_key(&server_id(2)), + "the new server's health entry is present", + ); + } +} diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 358a9ad267e6e5..cf35e2e1e160d0 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -59,7 +59,7 @@ snippet.workspace = true task.workspace = true terminal.workspace = true theme.workspace = true -tree-sitter = { workspace = true, optional = true } +tree-sitter = { workspace = true, optional = true , features = ["wasm"] } tree-sitter-gitcommit = { workspace = true, optional = true } url.workspace = true util.workspace = true @@ -78,5 +78,5 @@ tree-sitter-go.workspace = true tree-sitter-python.workspace = true tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true -tree-sitter.workspace = true +tree-sitter = { workspace = true, features = ["wasm"] } unindent.workspace = true diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 80d794cd617513..ee2d214caf611e 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -300,43 +300,43 @@ impl super::LspAdapter for CLspAdapter { ) -> Option { let name = &symbol.name; let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + language::SymbolKind::Method | language::SymbolKind::Function => { let text = format!("void {} () {{}}", name); let filter_range = 0..name.len(); let display_range = 5..5 + name.len(); (text, filter_range, display_range) } - lsp::SymbolKind::STRUCT => { + language::SymbolKind::Struct => { let text = format!("struct {} {{}}", name); let filter_range = 7..7 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::ENUM => { + language::SymbolKind::Enum => { let text = format!("enum {} {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::INTERFACE | lsp::SymbolKind::CLASS => { + language::SymbolKind::Interface | language::SymbolKind::Class => { let text = format!("class {} {{}}", name); let filter_range = 6..6 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CONSTANT => { + language::SymbolKind::Constant => { let text = format!("const int {} = 0;", name); let filter_range = 10..10 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::MODULE => { + language::SymbolKind::Module => { let text = format!("namespace {} {{}}", name); let filter_range = 10..10 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::TYPE_PARAMETER => { + language::SymbolKind::TypeParameter => { let text = format!("typename {} {{}};", name); let filter_range = 9..9 + name.len(); let display_range = 0..filter_range.end; @@ -451,6 +451,70 @@ mod tests { }); } + #[gpui::test] + async fn test_c_autoindent_switch_case(cx: &mut TestAppContext) { + cx.update(|cx| { + let test_settings = SettingsStore::test(cx); + cx.set_global(test_settings); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.tab_size = NonZeroU32::new(2); + }); + }); + }); + let language = crate::language("c", tree_sitter_c::LANGUAGE.into()); + + cx.new(|cx| { + let mut buffer = Buffer::local("", cx).with_language(language, cx); + + buffer.edit( + [( + 0..0, + r#" + int main() { + switch (a) { + case 1: + b++; + break; + case 2: + case 3: + c++; + break; + default: + d++; + } + } + "# + .unindent(), + )], + Some(AutoindentMode::EachLine), + cx, + ); + assert_eq!( + buffer.text(), + r#" + int main() { + switch (a) { + case 1: + b++; + break; + case 2: + case 3: + c++; + break; + default: + d++; + } + } + "# + .unindent(), + "statements under a case label should be indented, and the next label outdented" + ); + + buffer + }); + } + #[gpui::test] async fn test_c_autoindent_if_else(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/languages/src/cpp.rs b/crates/languages/src/cpp.rs index 0577a558260582..f09438a939abb1 100644 --- a/crates/languages/src/cpp.rs +++ b/crates/languages/src/cpp.rs @@ -16,6 +16,70 @@ mod tests { use std::num::NonZeroU32; use unindent::Unindent; + #[gpui::test] + async fn test_cpp_autoindent_switch_case(cx: &mut TestAppContext) { + cx.update(|cx| { + let test_settings = SettingsStore::test(cx); + cx.set_global(test_settings); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.tab_size = NonZeroU32::new(2); + }); + }); + }); + let language = crate::language("cpp", tree_sitter_cpp::LANGUAGE.into()); + + cx.new(|cx| { + let mut buffer = Buffer::local("", cx).with_language(language, cx); + + buffer.edit( + [( + 0..0, + r#" + int main() { + switch (a) { + case 1: + b++; + break; + case 2: + case 3: + c++; + break; + default: + d++; + } + } + "# + .unindent(), + )], + Some(AutoindentMode::EachLine), + cx, + ); + assert_eq!( + buffer.text(), + r#" + int main() { + switch (a) { + case 1: + b++; + break; + case 2: + case 3: + c++; + break; + default: + d++; + } + } + "# + .unindent(), + "statements under a case label should be indented, and the next label outdented" + ); + + buffer + }); + } + #[gpui::test] async fn test_cpp_autoindent_access_specifier(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 2d0c88bd016b8a..a876f392b9f366 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -286,7 +286,7 @@ impl LspAdapter for EsLintLspAdapter { let file_path = requested_file_path .as_ref() .and_then(|abs_path| abs_path.strip_prefix(worktree_root).ok()) - .and_then(|p| RelPath::unix(&p).ok().map(ToOwned::to_owned)) + .and_then(|p| RelPath::from_unix_str(&p).ok().map(ToOwned::to_owned)) .unwrap_or_else(|| RelPath::empty().to_owned()); let override_options = cx.update(|cx| { language_server_settings_for( diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 13a4fcc9bdf787..d2e68be4f12ca5 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -394,43 +394,43 @@ impl LspAdapter for GoLspAdapter { ) -> Option { let name = &symbol.name; let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + language::SymbolKind::Method | language::SymbolKind::Function => { let text = format!("func {} () {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::STRUCT => { + language::SymbolKind::Struct => { let text = format!("type {} struct {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..text.len(); (text, filter_range, display_range) } - lsp::SymbolKind::INTERFACE => { + language::SymbolKind::Interface => { let text = format!("type {} interface {{}}", name); let filter_range = 5..5 + name.len(); let display_range = 0..text.len(); (text, filter_range, display_range) } - lsp::SymbolKind::CLASS => { + language::SymbolKind::Class => { let text = format!("type {} T", name); let filter_range = 5..5 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CONSTANT => { + language::SymbolKind::Constant => { let text = format!("const {} = nil", name); let filter_range = 6..6 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::VARIABLE => { + language::SymbolKind::Variable => { let text = format!("var {} = nil", name); let filter_range = 4..4 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::MODULE => { + language::SymbolKind::Module => { let text = format!("package {}", name); let filter_range = 8..8 + name.len(); let display_range = 0..filter_range.end; @@ -945,6 +945,7 @@ mod tests { use gpui::{AppContext, Hsla, TestAppContext}; use task::TaskContext; use theme::SyntaxTheme; + use unindent::Unindent as _; fn go_language() -> Arc { let language = language("go", tree_sitter_go::LANGUAGE.into()); @@ -2017,6 +2018,89 @@ mod tests { ); } + #[gpui::test] + fn test_go_outline_includes_methods_with_receiver_forms(cx: &mut TestAppContext) { + let language = go_language(); + + let source = r#" + package main + + type v2 struct{} + + func (v2) BrokenMethod() { + println("start") + } + + func (_ v2) UnderscoreReceiverMethod() { + println("start") + } + + func (v v2) NamedReceiverMethod() { + println("start") + } + + func (v *v2) PointerReceiverMethod() { + println("start") + } + + func WorkingFunction() { + println("start") + } + "# + .unindent(); + + let buffer = + cx.new(|cx| crate::Buffer::local(source.clone(), cx).with_language(language, cx)); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let outline = snapshot.outline(None); + + assert_eq!( + outline + .items + .iter() + .map(|item| item.text.as_str()) + .collect::>(), + &[ + "type v2", + "func (v2) BrokenMethod", + "func (_ v2) UnderscoreReceiverMethod", + "func (v v2) NamedReceiverMethod", + "func (v *v2) PointerReceiverMethod", + "func WorkingFunction", + ] + ); + + for (method_name, expected_symbol) in [ + ("BrokenMethod", "func (v2) BrokenMethod"), + ( + "UnderscoreReceiverMethod", + "func (_ v2) UnderscoreReceiverMethod", + ), + ("NamedReceiverMethod", "func (v v2) NamedReceiverMethod"), + ( + "PointerReceiverMethod", + "func (v *v2) PointerReceiverMethod", + ), + ("WorkingFunction", "func WorkingFunction"), + ] { + let method_position = source + .find(&format!("{method_name}()")) + .expect("method should exist in source"); + let body_position = source[method_position..] + .find("println") + .map(|body_offset| method_position + body_offset) + .expect("method should contain a body"); + let symbols = snapshot.symbols_containing(body_position, None); + assert_eq!( + symbols + .last() + .map(|item| item.text.as_str()) + .expect("method should have an outline symbol"), + expected_symbol + ); + } + } + #[test] fn test_extract_subtest_name() { // Interpreted string literal diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index b97429c685b839..ff955fe0869261 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -52,8 +52,12 @@ impl ContextProvider for JsonTaskProvider { 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()); - let is_composer_json = file.path.ends_with(RelPath::unix("composer.json").unwrap()); + let is_package_json = file + .path + .ends_with(RelPath::from_unix_str("package.json").unwrap()); + let is_composer_json = file + .path + .ends_with(RelPath::from_unix_str("composer.json").unwrap()); if !is_package_json && !is_composer_json { return Task::ready(None); } diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 7159379aed62a2..01d2b450c5beb8 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -90,14 +90,14 @@ impl ManifestProvider for PyprojectTomlManifestProvider { let mut outermost_workspace_root = None; for path in path.ancestors().take(depth) { - let pyproject_path = path.join(RelPath::unix("pyproject.toml").unwrap()); + let pyproject_path = path.join(RelPath::from_unix_str("pyproject.toml").unwrap()); if delegate.exists(&pyproject_path, Some(false)) { if innermost_pyproject.is_none() { innermost_pyproject = Some(Arc::from(path)); } let has_lockfile = WORKSPACE_LOCKFILES.iter().any(|lockfile| { - let lockfile_path = path.join(RelPath::unix(lockfile).unwrap()); + let lockfile_path = path.join(RelPath::from_unix_str(lockfile).unwrap()); delegate.exists(&lockfile_path, Some(false)) }); if has_lockfile { @@ -220,19 +220,19 @@ fn label_for_python_symbol( ) -> Option { let name = &symbol.name; let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + language::SymbolKind::Method | language::SymbolKind::Function => { let text = format!("def {}():\n", name); let filter_range = 4..4 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CLASS => { + language::SymbolKind::Class => { let text = format!("class {}:", name); let filter_range = 6..6 + name.len(); let display_range = 0..filter_range.end; (text, filter_range, display_range) } - lsp::SymbolKind::CONSTANT => { + language::SymbolKind::Constant => { let text = format!("{} = 0", name); let filter_range = 0..name.len(); let display_range = 0..filter_range.end; @@ -669,7 +669,7 @@ impl LspAdapter for PyrightLspAdapter { // If we have a detected toolchain, configure Pyright to use it - unless the user sets it themselves. let should_insert_toolchain = || { user_settings.as_object().is_none_or(|object| { - [ + ![ "venvPath", "venv", "python", @@ -1114,7 +1114,7 @@ impl PythonContextProvider { fn python_module_name_from_relative_path(relative_path: &str) -> Option { let rel_path = RelPath::new(relative_path.as_ref(), PathStyle::local()).ok()?; - let path_with_dots = rel_path.display(PathStyle::Posix).replace('/', "."); + let path_with_dots = rel_path.display(PathStyle::Unix).replace('/', "."); Some( path_with_dots .strip_suffix(".py") @@ -2112,10 +2112,10 @@ impl LspAdapter for BasedPyrightLspAdapter { .and_then(|s| s.settings.clone()) .unwrap_or_default(); - // If we have a detected toolchain, configure Pyright to use it + // If we have a detected toolchain, configure BasedPyright to use it - unless the user sets it themselves. let should_insert_toolchain = || { user_settings.as_object().is_none_or(|object| { - [ + ![ "venvPath", "venv", "python", @@ -3265,7 +3265,7 @@ mod tests { }); let provider = PyprojectTomlManifestProvider; provider.search(ManifestQuery { - path: RelPath::unix(query_path).unwrap().into(), + path: RelPath::from_unix_str(query_path).unwrap().into(), depth: 10, delegate, }) @@ -3274,7 +3274,7 @@ mod tests { #[test] fn test_simple_project_no_lockfile() { let result = search(&["project/pyproject.toml"], "project/src/main.py"); - assert_eq!(result.as_deref(), RelPath::unix("project").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("project").ok()); } #[test] @@ -3287,7 +3287,7 @@ mod tests { ], "packages/subproject/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3296,7 +3296,7 @@ mod tests { &["pyproject.toml", "poetry.lock", "libs/mylib/pyproject.toml"], "libs/mylib/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3309,7 +3309,7 @@ mod tests { ], "packages/mypackage/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3318,13 +3318,19 @@ mod tests { &["project-a/pyproject.toml", "project-b/pyproject.toml"], "project-a/src/main.py", ); - assert_eq!(result_a.as_deref(), RelPath::unix("project-a").ok()); + assert_eq!( + result_a.as_deref(), + RelPath::from_unix_str("project-a").ok() + ); let result_b = search( &["project-a/pyproject.toml", "project-b/pyproject.toml"], "project-b/src/main.py", ); - assert_eq!(result_b.as_deref(), RelPath::unix("project-b").ok()); + assert_eq!( + result_b.as_deref(), + RelPath::from_unix_str("project-b").ok() + ); } #[test] @@ -3345,7 +3351,7 @@ mod tests { ], "packages/sub/src/main.py", ); - assert_eq!(result.as_deref(), RelPath::unix("").ok()); + assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok()); } #[test] @@ -3360,11 +3366,16 @@ mod tests { // "deep/nested/src/main.py", "deep/nested/src", and "deep/nested" // It won't reach "deep" or root "" let result = provider.search(ManifestQuery { - path: RelPath::unix("deep/nested/src/main.py").unwrap().into(), + path: RelPath::from_unix_str("deep/nested/src/main.py") + .unwrap() + .into(), depth: 3, delegate, }); - assert_eq!(result.as_deref(), RelPath::unix("deep/nested").ok()); + assert_eq!( + result.as_deref(), + RelPath::from_unix_str("deep/nested").ok() + ); } } } diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 52e9a902a07eeb..16e0ff01fd559a 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -292,7 +292,7 @@ impl ManifestProvider for CargoManifestProvider { ) -> Option> { let mut outermost_cargo_toml = None; for path in path.ancestors().take(depth) { - let p = path.join(RelPath::unix("Cargo.toml").unwrap()); + let p = path.join(RelPath::from_unix_str("Cargo.toml").unwrap()); if delegate.exists(&p, Some(false)) { outermost_cargo_toml = Some(Arc::from(path)); } @@ -489,27 +489,56 @@ impl LspAdapter for RustLspAdapter { .collect::>(); all_stop_ranges.sort_unstable_by_key(|a| (a.start, Reverse(a.end))); + // Placeholders may nest, e.g. `$2` inside `${1:"$2"}` + struct OpenPlaceholder { + snippet_text_end: usize, + label_run_start: usize, + } + let mut open_placeholders = SmallVec::<[OpenPlaceholder; 4]>::new(); + for range in &all_stop_ranges { let start_pos = range.start as usize; let end_pos = range.end as usize; + while let Some(placeholder) = open_placeholders.last() { + if placeholder.snippet_text_end > start_pos { + break; + } + label.push_str(&snippet.text[text_pos..placeholder.snippet_text_end]); + text_pos = placeholder.snippet_text_end; + runs.push(( + placeholder.label_run_start..label.len(), + HighlightId::TABSTOP_REPLACE_ID, + )); + open_placeholders.pop(); + } + label.push_str(&snippet.text[text_pos..start_pos]); + text_pos = start_pos; if start_pos == end_pos { let caret_start = label.len(); label.push('…'); runs.push((caret_start..label.len(), HighlightId::TABSTOP_INSERT_ID)); } else { - let label_start = label.len(); - label.push_str(&snippet.text[start_pos..end_pos]); - let label_end = label.len(); - runs.push((label_start..label_end, HighlightId::TABSTOP_REPLACE_ID)); + open_placeholders.push(OpenPlaceholder { + snippet_text_end: end_pos, + label_run_start: label.len(), + }); } + } - text_pos = end_pos; + while let Some(placeholder) = open_placeholders.pop() { + label.push_str(&snippet.text[text_pos..placeholder.snippet_text_end]); + text_pos = placeholder.snippet_text_end; + runs.push(( + placeholder.label_run_start..label.len(), + HighlightId::TABSTOP_REPLACE_ID, + )); } label.push_str(&snippet.text[text_pos..]); + runs.sort_unstable_by_key(|(range, _)| (range.start, Reverse(range.end))); if detail_left.is_some_and(|detail_left| detail_left == new_text) { // We only include the left detail if it isn't the snippet again @@ -622,15 +651,15 @@ impl LspAdapter for RustLspAdapter { ) -> Option { let name = &symbol.name; let (prefix, suffix) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => ("fn ", "();"), - lsp::SymbolKind::STRUCT => ("struct ", ";"), - lsp::SymbolKind::ENUM => ("enum ", "{}"), - lsp::SymbolKind::INTERFACE => ("trait ", "{}"), - lsp::SymbolKind::CONSTANT => ("const ", ":()=();"), - lsp::SymbolKind::MODULE => ("mod ", ";"), - lsp::SymbolKind::PACKAGE => ("extern crate ", ";"), - lsp::SymbolKind::TYPE_PARAMETER => ("type ", "=();"), - lsp::SymbolKind::ENUM_MEMBER => { + language::SymbolKind::Method | language::SymbolKind::Function => ("fn ", "();"), + language::SymbolKind::Struct => ("struct ", ";"), + language::SymbolKind::Enum => ("enum ", "{}"), + language::SymbolKind::Interface => ("trait ", "{}"), + language::SymbolKind::Constant => ("const ", ":()=();"), + language::SymbolKind::Module => ("mod ", ";"), + language::SymbolKind::Package => ("extern crate ", ";"), + language::SymbolKind::TypeParameter => ("type ", "=();"), + language::SymbolKind::EnumMember => { let prefix = "enum E {"; return Some(CodeLabel::new( name.to_string(), @@ -1453,6 +1482,7 @@ mod tests { use crate::language; use gpui::{BorrowAppContext, Hsla, TestAppContext}; use lsp::CompletionItemLabelDetails; + use pretty_assertions::assert_eq; use settings::SettingsStore; use theme::SyntaxTheme; use util::path; @@ -1876,6 +1906,34 @@ mod tests { )) ); + assert_eq!( + adapter + .label_for_completion( + &lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::SNIPPET), + label: "unimplemented".to_string(), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::default(), + new_text: "unimplemented!(${1:\"$2\"})".to_string(), + })), + ..lsp::CompletionItem::default() + }, + &language, + ) + .await, + Some(CodeLabel::new( + "unimplemented!(\"…\")".to_string(), + 0..13, + vec![ + (15..20, HighlightId::TABSTOP_REPLACE_ID), + (16..19, HighlightId::TABSTOP_INSERT_ID), + (0..13, HighlightId::new(2)), + (13..14, HighlightId::new(2)), + ], + )) + ); + // Postfix completion without actual tabstops (only implicit final $0) // The label should use completion.label so it can be filtered by "ref" let ref_completion = adapter @@ -1972,7 +2030,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "hello".to_string(), - kind: lsp::SymbolKind::FUNCTION, + kind: language::SymbolKind::Function, container_name: None, }, &language @@ -1990,7 +2048,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "World".to_string(), - kind: lsp::SymbolKind::TYPE_PARAMETER, + kind: language::SymbolKind::TypeParameter, container_name: None, }, &language @@ -2008,7 +2066,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "zed".to_string(), - kind: lsp::SymbolKind::PACKAGE, + kind: language::SymbolKind::Package, container_name: None, }, &language @@ -2026,7 +2084,7 @@ mod tests { .label_for_symbol( &language::Symbol { name: "Variant".to_string(), - kind: lsp::SymbolKind::ENUM_MEMBER, + kind: language::SymbolKind::EnumMember, container_name: None, }, &language diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index 5572e43f0492bc..0da1b63590e14f 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -12,7 +12,7 @@ use language::{ use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; -use semver::Version; +use semver::{Version, VersionReq}; use serde_json::{Value, json}; use smol::lock::RwLock; use std::{ @@ -601,6 +601,9 @@ fn replace_test_name_parameters(test_name: &str) -> String { PATTERN.split(test_name).map(regex::escape).join("(.+?)") } +static TYPESCRIPT_VERSION_REQ: LazyLock = + LazyLock::new(|| VersionReq::parse("^6").expect("Failed to parse TypeScript version req")); + pub struct TypeScriptLspAdapter { fs: Arc, node: NodeRuntime, @@ -622,7 +625,9 @@ impl TypeScriptLspAdapter { async fn tsdk_path(&self, adapter: &Arc) -> Option<&'static str> { let is_yarn = adapter - .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap()) + .read_text_file( + RelPath::from_unix_str(".yarn/sdks/typescript/lib/typescript.js").unwrap(), + ) .await .is_ok(); @@ -632,9 +637,16 @@ impl TypeScriptLspAdapter { "node_modules/typescript/lib" }; + // typescript-language-server doesn't support TypeScript 7+, which no longer + // ships `tsserver.js`. if self .fs - .is_dir(&adapter.worktree_root_path().join(tsdk_path)) + .is_file( + &adapter + .worktree_root_path() + .join(tsdk_path) + .join("tsserver.js"), + ) .await { Some(tsdk_path) @@ -661,7 +673,10 @@ impl LspInstaller for TypeScriptLspAdapter { Ok(TypeScriptVersions { typescript_version: self .node - .npm_package_latest_version(Self::PACKAGE_NAME) + .npm_package_latest_version_with_requirement( + Self::PACKAGE_NAME, + Some(&TYPESCRIPT_VERSION_REQ), + ) .await?, server_version: self .node @@ -684,12 +699,13 @@ impl LspInstaller for TypeScriptLspAdapter { async move { let server_path = container_dir.join(Self::NEW_SERVER_PATH); + // Pin rather than Latest so an unusable TypeScript 7.x install gets downgraded. if node .should_install_npm_package( Self::PACKAGE_NAME, &server_path, &container_dir, - VersionStrategy::Latest(&typescript_version), + VersionStrategy::Pin(&typescript_version), ) .await { @@ -718,7 +734,7 @@ impl LspInstaller for TypeScriptLspAdapter { fn fetch_server_binary( &self, - _latest_version: Self::BinaryVersion, + latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -726,10 +742,14 @@ impl LspInstaller for TypeScriptLspAdapter { async move { let server_path = container_dir.join(Self::NEW_SERVER_PATH); + let typescript_version = latest_version.typescript_version.to_string(); - node.npm_install_latest_packages( + node.npm_install_packages( &container_dir, - &[Self::PACKAGE_NAME, Self::SERVER_PACKAGE_NAME], + &[ + (Self::PACKAGE_NAME, typescript_version.as_str()), + (Self::SERVER_PACKAGE_NAME, "latest"), + ], ) .await?; @@ -1433,6 +1453,184 @@ mod tests { ); } + #[gpui::test] + async fn test_conditional_test_wrappers(cx: &mut TestAppContext) { + for language in [ + crate::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ), + crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()), + crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()), + ] { + let text = r#" + it.runIf(true)("runIf test", () => { + true; + }); + + it.skipIf(false)("skipIf test", () => { + true; + }); + + test.runIf(true)("runIf test 2", () => { + true; + }); + + test.skipIf(false)("skipIf test 2", () => { + true; + }); + + describe.runIf(true)("runIf describe", () => { + it("inner test", () => { + true; + }); + }); + + describe.skipIf(false)("skipIf describe", () => { + it("inner test 2", () => { + true; + }); + }); + + it.todoIf(false)("todoIf test", () => { + true; + }); + + it.if(true)("if test", () => { + true; + }); + + test.todoIf(false)("todoIf test 2", () => { + true; + }); + + test.if(true)("if test 2", () => { + true; + }); + + describe.todoIf(false)("todoIf describe", () => { + it("inner todoIf", () => { + true; + }); + }); + + describe.if(true)("if describe", () => { + it("inner if", () => { + true; + }); + }); + + test.failing("failing test", () => { + true; + }); + + it.failing("failing it", () => { + true; + }); + + it.each([1, 2, 3])("each test", () => { + true; + }); + + describe.each([1, 2])("each describe", () => { + it("inner each", () => { + true; + }); + }); + + it.skip("skip test", () => { + true; + }); + + it.only("only test", () => { + true; + }); + + it.todo("todo test"); + "# + .unindent(); + + let text_len = text.len(); + let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); + cx.executor().run_until_parked(); + + let outline = buffer.update(cx, |buffer, _cx| buffer.snapshot().outline(None)); + let outline_names = outline + .items + .iter() + .map(|item| item.text.as_str()) + .collect::>(); + assert_eq!( + outline_names, + [ + "runIf test", + "skipIf test", + "runIf test 2", + "skipIf test 2", + "runIf describe", + "it inner test", + "skipIf describe", + "it inner test 2", + "todoIf test", + "if test", + "todoIf test 2", + "if test 2", + "todoIf describe", + "it inner todoIf", + "if describe", + "it inner if", + "test.failing failing test", + "it.failing failing it", + "each test", + "each describe", + "it inner each", + "it.skip skip test", + "it.only only test", + "it.todo todo test", + ] + ); + + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + let runnable_names = snapshot + .runnable_ranges(0..text_len) + .map(|runnable| { + snapshot + .text_for_range(runnable.run_range) + .collect::() + }) + .collect::>(); + assert_eq!( + runnable_names, + [ + "runIf test", + "skipIf test", + "runIf test 2", + "skipIf test 2", + "runIf describe", + "inner test", + "skipIf describe", + "inner test 2", + "todoIf test", + "if test", + "todoIf test 2", + "if test 2", + "todoIf describe", + "inner todoIf", + "if describe", + "inner if", + "failing test", + "failing it", + "each test", + "each describe", + "inner each", + "skip test", + "only test", + "todo test", + ] + ); + } + } + #[gpui::test] async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index acf8aea5e59179..5be98804d696a7 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -39,7 +39,6 @@ impl VtslsLspAdapter { const PACKAGE_NAME: &'static str = "@vtsls/language-server"; const SERVER_PATH: &'static str = "node_modules/@vtsls/language-server/bin/vtsls.js"; - const TYPESCRIPT_PACKAGE_NAME: &'static str = "typescript"; const TYPESCRIPT_TSDK_PATH: &'static str = "node_modules/typescript/lib"; const TYPESCRIPT_YARN_TSDK_PATH: &'static str = ".yarn/sdks/typescript/lib"; @@ -58,9 +57,15 @@ impl VtslsLspAdapter { Self::TYPESCRIPT_TSDK_PATH }; + // vtsls doesn't support TypeScript 7+, which no longer ships `tsserver.js`. if self .fs - .is_dir(&adapter.worktree_root_path().join(tsdk_path)) + .is_file( + &adapter + .worktree_root_path() + .join(tsdk_path) + .join("tsserver.js"), + ) .await { Some(tsdk_path) @@ -84,15 +89,10 @@ impl VtslsLspAdapter { } } -pub struct TypeScriptVersions { - typescript_version: Version, - server_version: Version, -} - const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("vtsls"); impl LspInstaller for VtslsLspAdapter { - type BinaryVersion = TypeScriptVersions; + type BinaryVersion = Version; async fn fetch_latest_server_version( &self, @@ -100,13 +100,9 @@ impl LspInstaller for VtslsLspAdapter { _: bool, _: &mut AsyncApp, ) -> Result { - Ok(TypeScriptVersions { - typescript_version: self.node.npm_package_latest_version("typescript").await?, - server_version: self - .node - .npm_package_latest_version("@vtsls/language-server") - .await?, - }) + self.node + .npm_package_latest_version(Self::PACKAGE_NAME) + .await } async fn check_if_user_installed( @@ -135,11 +131,8 @@ impl LspInstaller for VtslsLspAdapter { async move { let server_path = container_dir.join(Self::SERVER_PATH); - node.npm_install_latest_packages( - &container_dir, - &[Self::PACKAGE_NAME, Self::TYPESCRIPT_PACKAGE_NAME], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, @@ -156,8 +149,7 @@ impl LspInstaller for VtslsLspAdapter { _: &Arc, ) -> impl Send + Future> + use<> { let node = self.node.clone(); - let typescript_version = version.typescript_version.clone(); - let server_version = version.server_version.clone(); + let server_version = version.clone(); let container_dir = container_dir.clone(); async move { @@ -175,18 +167,6 @@ impl LspInstaller for VtslsLspAdapter { return None; } - if node - .should_install_npm_package( - Self::TYPESCRIPT_PACKAGE_NAME, - &container_dir.join(Self::TYPESCRIPT_TSDK_PATH), - &container_dir, - VersionStrategy::Latest(&typescript_version), - ) - .await - { - return None; - } - Some(LanguageServerBinary { path: node.binary_path().await.ok()?, env: None, diff --git a/crates/line_ending_selector/src/line_ending_indicator.rs b/crates/line_ending_selector/src/line_ending_indicator.rs index 419c63d0ac8fdf..cd97e1e54f824a 100644 --- a/crates/line_ending_selector/src/line_ending_indicator.rs +++ b/crates/line_ending_selector/src/line_ending_indicator.rs @@ -40,6 +40,7 @@ impl Render for LineEndingIndicator { el.child( Button::new("change-line-ending", line_ending.label()) .label_size(LabelSize::Small) + .tab_index(0isize) .on_click(cx.listener(|this, _, window, cx| { if let Some(editor) = this.active_editor.as_ref() { LineEndingSelector::toggle(editor, window, cx); diff --git a/crates/livekit_api/Cargo.toml b/crates/livekit_api/Cargo.toml index 2b2438c25e6d3c..80ba67d9941c30 100644 --- a/crates/livekit_api/Cargo.toml +++ b/crates/livekit_api/Cargo.toml @@ -13,6 +13,9 @@ workspace = true path = "src/livekit_api.rs" doctest = false +[features] +test-support = [] + [dependencies] anyhow.workspace = true async-trait.workspace = true diff --git a/crates/livekit_api/src/livekit_api.rs b/crates/livekit_api/src/livekit_api.rs index 745f511b12e177..250f56b3fd934d 100644 --- a/crates/livekit_api/src/livekit_api.rs +++ b/crates/livekit_api/src/livekit_api.rs @@ -31,10 +31,25 @@ pub struct LiveKitClient { url: Arc, key: Arc, secret: Arc, + timestamp_source: Arc, } impl LiveKitClient { - pub fn new(mut url: String, key: String, secret: String) -> Self { + pub fn new(url: String, key: String, secret: String) -> Self { + Self::new_with_timestamp_source( + url, + key, + secret, + Arc::new(token::SystemUnixTimestampSource), + ) + } + + pub(crate) fn new_with_timestamp_source( + mut url: String, + key: String, + secret: String, + timestamp_source: Arc, + ) -> Self { if url.ends_with('/') { url.pop(); } @@ -47,6 +62,7 @@ impl LiveKitClient { url: url.into(), key: key.into(), secret: secret.into(), + timestamp_source, } } @@ -61,7 +77,13 @@ impl LiveKitClient { Res: Default + Message, { let client = self.http.clone(); - let token = token::create(&self.key, &self.secret, None, grant); + let token = token::create_with_timestamp_source( + &self.key, + &self.secret, + None, + grant, + self.timestamp_source.as_ref(), + ); let url = format!("{}/{}", self.url, path); log::info!("Request {}: {:?}", url, body); async move { @@ -163,20 +185,22 @@ impl Client for LiveKitClient { } fn room_token(&self, room: &str, identity: &str) -> Result { - token::create( + token::create_with_timestamp_source( &self.key, &self.secret, Some(identity), token::VideoGrant::to_join(room), + self.timestamp_source.as_ref(), ) } fn guest_token(&self, room: &str, identity: &str) -> Result { - token::create( + token::create_with_timestamp_source( &self.key, &self.secret, Some(identity), token::VideoGrant::for_guest(room), + self.timestamp_source.as_ref(), ) } } diff --git a/crates/livekit_api/src/token.rs b/crates/livekit_api/src/token.rs index 6f12d78855106c..5229a83bb6e968 100644 --- a/crates/livekit_api/src/token.rs +++ b/crates/livekit_api/src/token.rs @@ -1,14 +1,25 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, - ops::Add, time::{Duration, SystemTime, UNIX_EPOCH}, }; const DEFAULT_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6 hours +pub trait UnixTimestampSource: Send + Sync { + fn unix_timestamp(&self) -> Result; +} + +pub struct SystemUnixTimestampSource; + +impl UnixTimestampSource for SystemUnixTimestampSource { + fn unix_timestamp(&self) -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) + } +} + #[derive(Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClaimGrants<'a> { @@ -73,22 +84,55 @@ pub fn create( identity: Option<&str>, video_grant: VideoGrant, ) -> Result { - if video_grant.room_join.is_some() && identity.is_none() { + create_with_timestamp_source( + api_key, + secret_key, + identity, + video_grant, + &SystemUnixTimestampSource, + ) +} + +pub fn create_with_timestamp_source( + api_key: &str, + secret_key: &str, + identity: Option<&str>, + video_grant: VideoGrant, + timestamp_source: &dyn UnixTimestampSource, +) -> Result { + let issued_at = timestamp_source.unix_timestamp()?; + create_with_issued_at(api_key, secret_key, identity, video_grant, issued_at) +} + +fn create_with_issued_at( + api_key: &str, + secret_key: &str, + identity: Option<&str>, + video_grant: VideoGrant, + issued_at: u64, +) -> Result { + let room_join = video_grant.room_join.unwrap_or(false); + if room_join && identity.is_none() { anyhow::bail!("identity is required for room_join grant, but it is none"); } - let now = SystemTime::now(); + let expires_at = issued_at + .checked_add(DEFAULT_TTL.as_secs()) + .context("token expiration overflow")?; + let not_before = if room_join { + // LiveKit Cloud applies participant revocations by comparing the + // revocation timestamp to room-join token `nbf`. + issued_at + } else { + 0 + }; let claims = ClaimGrants { iss: Cow::Borrowed(api_key), sub: identity.map(Cow::Borrowed), - iat: now.duration_since(UNIX_EPOCH).unwrap().as_secs(), - exp: now - .add(DEFAULT_TTL) - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - nbf: 0, + iat: issued_at, + exp: expires_at, + nbf: not_before, jwtid: identity.map(Cow::Borrowed), video: video_grant, }; @@ -108,3 +152,127 @@ pub fn validate<'a>(token: &'a str, secret_key: &str) -> Result> Ok(token.claims) } + +#[cfg(any(test, feature = "test-support"))] +pub fn validate_with_timestamp_source<'a>( + token: &'a str, + secret_key: &str, + timestamp_source: &dyn UnixTimestampSource, +) -> Result> { + let mut validation = Validation::default(); + validation.validate_exp = false; + validation.validate_nbf = false; + let token: jsonwebtoken::TokenData> = jsonwebtoken::decode( + token, + &DecodingKey::from_secret(secret_key.as_ref()), + &validation, + )?; + let claims = token.claims; + let timestamp = timestamp_source.unix_timestamp()?; + + anyhow::ensure!(claims.nbf <= timestamp, "token is not yet valid"); + anyhow::ensure!(claims.exp > timestamp, "token has expired"); + + Ok(claims) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Client as _, LiveKitClient}; + use std::sync::Arc; + + const ISSUED_AT: u64 = 1_234_567; + + struct FixedUnixTimestampSource(u64); + + impl UnixTimestampSource for FixedUnixTimestampSource { + fn unix_timestamp(&self) -> Result { + Ok(self.0) + } + } + + #[test] + fn token_not_before_matches_issue_time() -> Result<()> { + let token = create_with_timestamp_source( + "api-key", + "secret-key", + Some("participant"), + VideoGrant::to_join("room"), + &FixedUnixTimestampSource(ISSUED_AT), + )?; + + assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?; + Ok(()) + } + + #[test] + fn room_token_not_before_matches_issue_time() -> Result<()> { + let client = LiveKitClient::new_with_timestamp_source( + "http://livekit.test".into(), + "api-key".into(), + "secret-key".into(), + Arc::new(FixedUnixTimestampSource(ISSUED_AT)), + ); + let token = client.room_token("room", "participant")?; + + let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?; + assert_eq!(claims.video.room_join, Some(true)); + assert_eq!(claims.video.can_publish, Some(true)); + assert_eq!(claims.video.can_subscribe, Some(true)); + + Ok(()) + } + + #[test] + fn guest_token_not_before_matches_issue_time() -> Result<()> { + let client = LiveKitClient::new_with_timestamp_source( + "http://livekit.test".into(), + "api-key".into(), + "secret-key".into(), + Arc::new(FixedUnixTimestampSource(ISSUED_AT)), + ); + let token = client.guest_token("room", "participant")?; + + let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?; + assert_eq!(claims.video.room_join, Some(true)); + assert_eq!(claims.video.can_publish, Some(false)); + assert_eq!(claims.video.can_subscribe, Some(true)); + + Ok(()) + } + + #[test] + fn admin_token_not_before_remains_unset() -> Result<()> { + let token = create_with_timestamp_source( + "api-key", + "secret-key", + None, + VideoGrant::to_admin("room"), + &FixedUnixTimestampSource(ISSUED_AT), + )?; + + let claims = assert_claims_timestamp(&token, ISSUED_AT, 0)?; + assert_eq!(claims.video.room_admin, Some(true)); + + Ok(()) + } + + fn assert_claims_timestamp( + token: &str, + issued_at: u64, + expected_not_before: u64, + ) -> Result> { + let claims = validate_with_timestamp_source( + token, + "secret-key", + &FixedUnixTimestampSource(issued_at), + )?; + + assert_eq!(claims.iat, issued_at); + assert_eq!(claims.nbf, expected_not_before); + assert_eq!(claims.exp, issued_at + DEFAULT_TTL.as_secs()); + + Ok(claims) + } +} diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index 42c13f094c1893..a229d39ca8c72c 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -17,7 +17,7 @@ doctest = false name = "test_app" [features] -test-support = ["collections/test-support", "gpui/test-support"] +test-support = ["collections/test-support", "gpui/test-support", "livekit_api/test-support"] [dependencies] anyhow.workspace = true @@ -65,6 +65,7 @@ objc.workspace = true collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } gpui_platform.workspace = true +livekit_api = { workspace = true, features = ["test-support"] } simplelog.workspace = true [build-dependencies] diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs index 955f92dc19d012..d742dd06617197 100644 --- a/crates/livekit_client/src/test.rs +++ b/crates/livekit_client/src/test.rs @@ -57,6 +57,25 @@ pub struct TestServer { pub secret_key: String, rooms: Mutex>, executor: BackgroundExecutor, + timestamp_source: Arc, +} + +pub struct ManualUnixTimestampSource(AtomicU64); + +impl ManualUnixTimestampSource { + pub fn new(timestamp: u64) -> Self { + Self(AtomicU64::new(timestamp)) + } + + pub fn advance(&self) { + self.0.fetch_add(1, SeqCst); + } +} + +impl token::UnixTimestampSource for ManualUnixTimestampSource { + fn unix_timestamp(&self) -> Result { + Ok(self.0.load(SeqCst)) + } } impl TestServer { @@ -65,6 +84,22 @@ impl TestServer { api_key: String, secret_key: String, executor: BackgroundExecutor, + ) -> Result> { + Self::create_with_timestamp_source( + url, + api_key, + secret_key, + executor, + Arc::new(token::SystemUnixTimestampSource), + ) + } + + pub fn create_with_timestamp_source( + url: String, + api_key: String, + secret_key: String, + executor: BackgroundExecutor, + timestamp_source: Arc, ) -> Result> { let mut servers = SERVERS.lock(); if let BTreeEntry::Vacant(e) = servers.entry(url.clone()) { @@ -74,6 +109,7 @@ impl TestServer { secret_key, rooms: Default::default(), executor, + timestamp_source, }); e.insert(server.clone()); Ok(server) @@ -104,6 +140,20 @@ impl TestServer { } } + #[cfg(any(test, feature = "test-support"))] + fn validate_token<'a>(&self, token: &'a str) -> Result> { + token::validate_with_timestamp_source( + token, + &self.secret_key, + self.timestamp_source.as_ref(), + ) + } + + #[cfg(not(any(test, feature = "test-support")))] + fn validate_token<'a>(&self, token: &'a str) -> Result> { + token::validate(token, &self.secret_key) + } + pub async fn create_room(&self, room: String) -> Result<()> { self.simulate_random_delay().await; @@ -129,11 +179,19 @@ impl TestServer { async fn join_room(&self, token: String, client_room: Room) -> Result { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; - let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); - let room_name = claims.video.room.unwrap(); + let claims = self.validate_token(&token)?; + let identity = ParticipantIdentity( + claims + .sub + .context("missing participant identity")? + .to_string(), + ); + let room_name = claims.video.room.context("missing room name")?.to_string(); let mut server_rooms = self.rooms.lock(); - let room = (*server_rooms).entry(room_name.to_string()).or_default(); + let room = (*server_rooms).entry(room_name.clone()).or_default(); + if let Some(revoked_before) = room.token_revocations.get(&identity) { + anyhow::ensure!(claims.nbf >= *revoked_before, "invalid token: revoked"); + } if let Entry::Vacant(e) = room.client_rooms.entry(identity.clone()) { for server_track in &room.video_tracks { @@ -192,7 +250,7 @@ impl TestServer { async fn leave_room(&self, token: String) -> Result<()> { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -209,7 +267,7 @@ impl TestServer { &self, token: String, ) -> Result> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let local_identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap().to_string(); @@ -244,14 +302,25 @@ impl TestServer { identity: ParticipantIdentity, ) -> Result<()> { self.simulate_random_delay().await; + let revoked_before = self.timestamp_source.unix_timestamp()?; let mut server_rooms = self.rooms.lock(); let room = server_rooms .get_mut(&room_name) .with_context(|| format!("room {room_name} does not exist"))?; - room.client_rooms + let removed_room = room + .client_rooms .remove(&identity) .with_context(|| format!("participant {identity:?} did not join room {room_name:?}"))?; + room.token_revocations.insert(identity, revoked_before); + let mut removed_room = removed_room.0.lock(); + removed_room.connection_state = ConnectionState::Disconnected; + removed_room + .updates_tx + .blocking_send(RoomEvent::Disconnected { + reason: "PARTICIPANT_REMOVED", + }) + .ok(); Ok(()) } @@ -262,13 +331,18 @@ impl TestServer { permission: proto::ParticipantPermission, ) -> Result<()> { self.simulate_random_delay().await; + let revoked_before = self.timestamp_source.unix_timestamp()?; let mut server_rooms = self.rooms.lock(); let room = server_rooms .get_mut(&room_name) .with_context(|| format!("room {room_name} does not exist"))?; + let identity = ParticipantIdentity(identity); room.participant_permissions - .insert(ParticipantIdentity(identity), permission); + .insert(identity.clone(), permission); + // Permission changes in LiveKit Cloud invalidate existing participant + // tokens, so the mock needs to reject tokens minted before the update. + room.token_revocations.insert(identity, revoked_before); Ok(()) } @@ -298,7 +372,7 @@ impl TestServer { ) -> Result { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); @@ -362,7 +436,7 @@ impl TestServer { ) -> Result { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); @@ -421,7 +495,7 @@ impl TestServer { } pub(crate) async fn unpublish_track(&self, token: String, track_sid: &TrackSid) -> Result<()> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); @@ -503,7 +577,7 @@ impl TestServer { track_sid: &TrackSid, muted: bool, ) -> Result<()> { - let claims = livekit_api::token::validate(token, &self.secret_key)?; + let claims = self.validate_token(token)?; let room_name = claims.video.room.unwrap(); let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let mut server_rooms = self.rooms.lock(); @@ -557,7 +631,7 @@ impl TestServer { } pub(crate) fn is_track_muted(&self, token: &str, track_sid: &TrackSid) -> Option { - let claims = livekit_api::token::validate(token, &self.secret_key).ok()?; + let claims = self.validate_token(token).ok()?; let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -572,7 +646,7 @@ impl TestServer { } pub(crate) fn video_tracks(&self, token: String) -> Result> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let room_name = claims.video.room.unwrap(); let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); @@ -595,7 +669,7 @@ impl TestServer { } pub(crate) fn audio_tracks(&self, token: String) -> Result> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let room_name = claims.video.room.unwrap(); let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); @@ -629,6 +703,7 @@ struct TestServerRoom { video_tracks: Vec>, audio_tracks: Vec>, participant_permissions: HashMap, + token_revocations: HashMap, } #[derive(Debug)] @@ -690,21 +765,23 @@ impl livekit_api::Client for TestApiClient { fn room_token(&self, room: &str, identity: &str) -> Result { let server = TestServer::get(&self.url)?; - token::create( + token::create_with_timestamp_source( &server.api_key, &server.secret_key, Some(identity), token::VideoGrant::to_join(room), + server.timestamp_source.as_ref(), ) } fn guest_token(&self, room: &str, identity: &str) -> Result { let server = TestServer::get(&self.url)?; - token::create( + token::create_with_timestamp_source( &server.api_key, &server.secret_key, Some(identity), token::VideoGrant::for_guest(room), + server.timestamp_source.as_ref(), ) } } @@ -853,3 +930,168 @@ impl WeakRoom { self.0.upgrade().map(Room) } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use livekit_api::Client as _; + use std::{ops::Deref, sync::atomic::AtomicUsize}; + + struct TestServerGuard { + server: Arc, + timestamp_source: Arc, + } + + impl TestServerGuard { + fn advance_timestamp(&self) { + self.timestamp_source.advance(); + } + } + + impl Deref for TestServerGuard { + type Target = TestServer; + + fn deref(&self) -> &Self::Target { + self.server.as_ref() + } + } + + impl Drop for TestServerGuard { + fn drop(&mut self) { + self.server.teardown().ok(); + } + } + + fn create_test_server(name: &str, executor: BackgroundExecutor) -> TestServerGuard { + static NEXT_SERVER_ID: AtomicUsize = AtomicUsize::new(0); + let server_id = NEXT_SERVER_ID.fetch_add(1, SeqCst); + let timestamp_source = Arc::new(ManualUnixTimestampSource::new(1_234_567)); + let server = TestServer::create_with_timestamp_source( + format!("http://livekit-{name}-{server_id}.test"), + format!("api-key-{server_id}"), + format!("secret-key-{server_id}"), + executor, + timestamp_source.clone(), + ) + .expect("create LiveKit test server"); + TestServerGuard { + server, + timestamp_source, + } + } + + async fn assert_token_was_revoked(server: &TestServer, token: String, cx: &mut TestAppContext) { + match Room::connect(server.url.clone(), token, &mut cx.to_async()).await { + Ok(_) => panic!("revoked token unexpectedly connected"), + Err(error) => { + let error = format!("{error:#}"); + assert!( + error.contains("invalid token: revoked"), + "expected revoked token error, got {error}" + ); + } + } + } + + #[gpui::test] + async fn token_created_after_participant_removal_can_join( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { + let server = create_test_server("room-token", executor); + server + .create_room("room".into()) + .await + .expect("create LiveKit test room"); + let api_client = server.create_api_client(); + + let initial_token = api_client + .room_token("room", "participant") + .expect("create initial room token"); + let (initial_room, _) = Room::connect( + server.url.clone(), + initial_token.clone(), + &mut cx.to_async(), + ) + .await + .expect("connect with initial room token"); + + server.advance_timestamp(); + api_client + .remove_participant("room".into(), "participant".into()) + .await + .expect("remove participant"); + + assert_eq!( + initial_room.connection_state(), + ConnectionState::Disconnected + ); + assert_token_was_revoked(&server, initial_token, cx).await; + + let fresh_token = api_client + .room_token("room", "participant") + .expect("create fresh room token"); + let (fresh_room, _) = Room::connect(server.url.clone(), fresh_token, &mut cx.to_async()) + .await + .expect("connect with fresh room token"); + + assert_eq!(fresh_room.connection_state(), ConnectionState::Connected); + } + + #[gpui::test] + async fn guest_token_created_after_permission_update_can_join( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { + let server = create_test_server("guest-token", executor); + server + .create_room("room".into()) + .await + .expect("create LiveKit test room"); + let api_client = server.create_api_client(); + + let initial_token = api_client + .guest_token("room", "participant") + .expect("create initial guest token"); + let (initial_room, _) = Room::connect( + server.url.clone(), + initial_token.clone(), + &mut cx.to_async(), + ) + .await + .expect("connect with initial guest token"); + + server.advance_timestamp(); + api_client + .update_participant( + "room".into(), + "participant".into(), + proto::ParticipantPermission { + can_subscribe: true, + can_publish: true, + can_publish_data: true, + hidden: false, + recorder: false, + }, + ) + .await + .expect("update participant permissions"); + assert_token_was_revoked(&server, initial_token, cx).await; + + server.disconnect_client("participant".into()).await; + assert_eq!( + initial_room.connection_state(), + ConnectionState::Disconnected + ); + + let fresh_token = api_client + .guest_token("room", "participant") + .expect("create fresh guest token"); + let (fresh_room, _) = Room::connect(server.url.clone(), fresh_token, &mut cx.to_async()) + .await + .expect("connect with fresh guest token"); + + assert_eq!(fresh_room.connection_state(), ConnectionState::Connected); + } +} diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs index 57963bbb040c07..4f5c977f1296ab 100644 --- a/crates/lmstudio/src/lmstudio.rs +++ b/crates/lmstudio/src/lmstudio.rs @@ -207,12 +207,19 @@ pub struct FunctionContent { pub arguments: String, } +#[derive(Serialize, Debug)] +pub struct StreamOptions { + pub include_usage: bool, +} + #[derive(Serialize, Debug)] pub struct ChatCompletionRequest { pub model: String, pub messages: Vec, pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub stop: Option>, diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml index 2c48575a648a9e..7bffc73f2f4a0c 100644 --- a/crates/lsp/Cargo.toml +++ b/crates/lsp/Cargo.toml @@ -13,25 +13,26 @@ path = "src/lsp.rs" doctest = false [features] -test-support = ["async-pipe", "gpui_util"] +test-support = ["async-pipe"] [dependencies] anyhow.workspace = true +async-channel.workspace = true async-pipe = { workspace = true, optional = true } collections.workspace = true -gpui_util = { workspace = true, optional = true } +futures-lite.workspace = true futures.workspace = true gpui.workspace = true +gpui_util.workspace = true log.workspace = true lsp-types.workspace = true parking_lot.workspace = true postage.workspace = true +release_channel.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true -schemars.workspace = true -smol.workspace = true util.workspace = true -release_channel.workspace = true [dev-dependencies] async-pipe.workspace = true diff --git a/crates/lsp/src/input_handler.rs b/crates/lsp/src/input_handler.rs index 679ae5c1f6cdeb..9173eeae85a519 100644 --- a/crates/lsp/src/input_handler.rs +++ b/crates/lsp/src/input_handler.rs @@ -6,11 +6,11 @@ use collections::HashMap; use futures::{ AsyncBufReadExt, AsyncRead, AsyncReadExt as _, SinkExt as _, channel::mpsc::{Receiver, Sender, channel}, + io::BufReader, }; use gpui::{BackgroundExecutor, Task}; use log::warn; use parking_lot::Mutex; -use smol::io::BufReader; use crate::{ AnyResponse, CONTENT_LEN_HEADER, IoHandler, IoKind, NotificationOrRequest, RequestId, @@ -190,12 +190,12 @@ mod tests { #[gpui::test] async fn test_read_headers() { let mut buf = Vec::new(); - let mut reader = smol::io::BufReader::new(b"Content-Length: 123\r\n\r\n" as &[u8]); + let mut reader = BufReader::new(b"Content-Length: 123\r\n\r\n" as &[u8]); read_headers(&mut reader, &mut buf).await.unwrap(); assert_eq!(buf, b"Content-Length: 123\r\n\r\n"); let mut buf = Vec::new(); - let mut reader = smol::io::BufReader::new(b"Content-Type: application/vscode-jsonrpc\r\nContent-Length: 1235\r\n\r\n{\"somecontent\":123}" as &[u8]); + let mut reader = BufReader::new(b"Content-Type: application/vscode-jsonrpc\r\nContent-Length: 1235\r\n\r\n{\"somecontent\":123}" as &[u8]); read_headers(&mut reader, &mut buf).await.unwrap(); assert_eq!( buf, @@ -203,7 +203,7 @@ mod tests { ); let mut buf = Vec::new(); - let mut reader = smol::io::BufReader::new(b"Content-Length: 1235\r\nContent-Type: application/vscode-jsonrpc\r\n\r\n{\"somecontent\":true}" as &[u8]); + let mut reader = BufReader::new(b"Content-Length: 1235\r\nContent-Type: application/vscode-jsonrpc\r\n\r\n{\"somecontent\":true}" as &[u8]); read_headers(&mut reader, &mut buf).await.unwrap(); assert_eq!( buf, diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 550b27e131609d..31554edd345052 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -6,10 +6,10 @@ pub use lsp_types::*; use anyhow::{Context as _, Result, anyhow}; use collections::{BTreeMap, HashMap}; use futures::{ - AsyncRead, AsyncWrite, Future, FutureExt, + AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, Future, FutureExt, StreamExt, channel::oneshot::{self, Canceled}, future::{self, Either}, - io::BufWriter, + io::{BufReader, BufWriter}, select, }; use gpui::{App, AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task}; @@ -19,12 +19,9 @@ use postage::{barrier, prelude::Stream}; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, json, value::RawValue}; -use smol::{ - channel, - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, -}; use util::command::{Child, Stdio}; +use gpui_util::{ResultExt, TryFutureExt}; use std::path::Path; use std::{ any::TypeId, @@ -42,7 +39,7 @@ use std::{ task::Poll, time::{Duration, Instant}, }; -use util::{ConnectionResult, ResultExt, TryFutureExt, redact}; +use util::{ConnectionResult, redact}; const JSON_RPC_VERSION: &str = "2.0"; const CONTENT_LEN_HEADER: &str = "Content-Length: "; @@ -60,6 +57,25 @@ pub const DEFAULT_LSP_REQUEST_TIMEOUT: Duration = /// The shutdown timeout for LSP servers (including Prettier/Copilot). const SERVER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +pub fn workspace_folder_for_uri(uri: Uri) -> WorkspaceFolder { + let name = uri + .to_file_path() + .ok() + .map(|path| { + let name = path.file_name().unwrap_or(path.as_os_str()); + name.to_string_lossy().into_owned() + }) + .filter(|name| !name.is_empty()) + .or_else(|| { + uri.path_segments() + .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) + .map(str::to_owned) + }) + .unwrap_or_else(|| uri.as_str().to_owned()); + + WorkspaceFolder { uri, name } +} + type NotificationHandler = Box, Value, &mut AsyncApp)>; type PendingRespondTasks = Arc>>>; type ResponseHandler = Box) -> Task<()>>; @@ -99,8 +115,8 @@ struct NotificationSerializer(Box String + Send + Sync>); pub struct LanguageServer { server_id: LanguageServerId, next_id: AtomicI32, - outbound_tx: channel::Sender, - notification_tx: channel::Sender, + outbound_tx: async_channel::Sender, + notification_tx: async_channel::Sender, name: LanguageServerName, version: Option, process_name: Arc, @@ -483,7 +499,7 @@ impl LanguageServer { Stderr: AsyncRead + Unpin + Send + 'static, F: Fn(&NotificationOrRequest) -> bool + 'static + Send + Sync + Clone, { - let (outbound_tx, outbound_rx) = channel::unbounded::(); + let (outbound_tx, outbound_rx) = async_channel::unbounded::(); let (output_done_tx, output_done_rx) = barrier::channel(); let notification_handlers = Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default())); @@ -563,7 +579,8 @@ impl LanguageServer { } .into(); - let (notification_tx, notification_rx) = channel::unbounded::(); + let (notification_tx, notification_rx) = + async_channel::unbounded::(); cx.background_spawn({ let outbound_tx = outbound_tx.clone(); async move { @@ -623,9 +640,8 @@ impl LanguageServer { where Stdout: AsyncRead + Unpin + Send + 'static, { - use smol::stream::StreamExt; let stdout = BufReader::new(stdout); - let _clear_response_handlers = util::defer({ + let _clear_response_handlers = gpui_util::defer({ let response_handlers = response_handlers.clone(); move || { response_handlers.lock().take(); @@ -667,7 +683,7 @@ impl LanguageServer { } // Don't starve the main thread when receiving lots of notifications at once. - smol::future::yield_now().await; + futures_lite::future::yield_now().await; } input_handler.loop_handle.await } @@ -703,13 +719,13 @@ impl LanguageServer { } // Don't starve the main thread when receiving lots of messages at once. - smol::future::yield_now().await; + futures_lite::future::yield_now().await; } } async fn handle_outgoing_messages( stdin: Stdin, - outbound_rx: channel::Receiver, + outbound_rx: async_channel::Receiver, output_done_tx: barrier::Sender, response_handlers: Arc>>>, io_handlers: Arc>>, @@ -750,21 +766,13 @@ impl LanguageServer { cx: &App, ) -> InitializeParams { let workspace_folders = self.workspace_folders.as_ref().map_or_else( - || { - vec![WorkspaceFolder { - name: Default::default(), - uri: self.root_uri.clone(), - }] - }, + || vec![workspace_folder_for_uri(self.root_uri.clone())], |folders| { folders .lock() .iter() .cloned() - .map(|uri| WorkspaceFolder { - name: Default::default(), - uri, - }) + .map(workspace_folder_for_uri) .collect() }, ); @@ -1426,8 +1434,8 @@ impl LanguageServer { fn request_internal_with_timer( next_id: &AtomicI32, response_handlers: &Arc>>>, - outbound_tx: &channel::Sender, - notification_serializers: &channel::Sender, + outbound_tx: &async_channel::Sender, + notification_serializers: &async_channel::Sender, executor: &BackgroundExecutor, timer: U, params: T::Params, @@ -1489,7 +1497,7 @@ impl LanguageServer { return ConnectionResult::Result(Err(e)); } - let cancel_on_drop = util::defer(move || { + let cancel_on_drop = gpui_util::defer(move || { if let Some(notification_serializers) = notification_serializers.upgrade() { Self::notify_internal::( ¬ification_serializers, @@ -1536,8 +1544,8 @@ impl LanguageServer { fn request_internal( next_id: &AtomicI32, response_handlers: &Arc>>>, - outbound_tx: &channel::Sender, - notification_serializers: &channel::Sender, + outbound_tx: &async_channel::Sender, + notification_serializers: &async_channel::Sender, executor: &BackgroundExecutor, request_timeout: Duration, params: T::Params, @@ -1588,7 +1596,7 @@ impl LanguageServer { } fn notify_internal( - outbound_tx: &channel::Sender, + outbound_tx: &async_channel::Sender, params: T::Params, ) -> Result<()> { let serializer = NotificationSerializer(Box::new(move || { @@ -1628,10 +1636,7 @@ impl LanguageServer { if is_new_folder { let params = DidChangeWorkspaceFoldersParams { event: WorkspaceFoldersChangeEvent { - added: vec![WorkspaceFolder { - uri, - name: String::default(), - }], + added: vec![workspace_folder_for_uri(uri)], removed: vec![], }, }; @@ -1663,10 +1668,7 @@ impl LanguageServer { let params = DidChangeWorkspaceFoldersParams { event: WorkspaceFoldersChangeEvent { added: vec![], - removed: vec![WorkspaceFolder { - uri, - name: String::default(), - }], + removed: vec![workspace_folder_for_uri(uri)], }, }; self.notify::(params).ok(); @@ -1681,18 +1683,14 @@ impl LanguageServer { let old_workspace_folders = std::mem::take(&mut *workspace_folders); let added: Vec<_> = folders .difference(&old_workspace_folders) - .map(|uri| WorkspaceFolder { - uri: uri.clone(), - name: String::default(), - }) + .cloned() + .map(workspace_folder_for_uri) .collect(); let removed: Vec<_> = old_workspace_folders .difference(&folders) - .map(|uri| WorkspaceFolder { - uri: uri.clone(), - name: String::default(), - }) + .cloned() + .map(workspace_folder_for_uri) .collect(); *workspace_folders = folders; let should_notify = !added.is_empty() || !removed.is_empty(); @@ -1822,7 +1820,7 @@ impl Drop for Subscription { pub struct FakeLanguageServer { pub binary: LanguageServerBinary, pub server: Arc, - notifications_rx: channel::Receiver<(String, String)>, + notifications_rx: async_channel::Receiver<(String, String)>, } #[cfg(any(test, feature = "test-support"))] @@ -1837,7 +1835,7 @@ impl FakeLanguageServer { ) -> (LanguageServer, FakeLanguageServer) { let (stdin_writer, stdin_reader) = async_pipe::pipe(); let (stdout_writer, stdout_reader) = async_pipe::pipe(); - let (notifications_tx, notifications_rx) = channel::unbounded(); + let (notifications_tx, notifications_rx) = async_channel::unbounded(); let server_name = LanguageServerName(name.clone().into()); let process_name = Arc::from(name.as_str()); @@ -2105,8 +2103,8 @@ mod tests { &mut cx.to_async(), ); - let (message_tx, message_rx) = channel::unbounded(); - let (diagnostics_tx, diagnostics_rx) = channel::unbounded(); + let (message_tx, message_rx) = async_channel::unbounded(); + let (diagnostics_tx, diagnostics_rx) = async_channel::unbounded(); server .on_notification::(move |params, _| { message_tx.try_send(params).unwrap() @@ -2294,7 +2292,7 @@ mod tests { } #[gpui::test] - async fn test_initialize_params_has_root_path_and_root_uri(cx: &mut TestAppContext) { + async fn test_default_initialize_params(cx: &mut TestAppContext) { cx.update(|cx| { release_channel::init(semver::Version::new(0, 0, 0), cx); }); @@ -2309,6 +2307,9 @@ mod tests { Default::default(), &mut cx.to_async(), ); + let project_uri = Uri::from_file_path(std::env::temp_dir().join("my project")) + .expect("workspace folder URI should be valid"); + server.set_workspace_folders(BTreeSet::from_iter([project_uri.clone()])); let params = cx.update(|cx| server.default_initialize_params(false, false, cx)); @@ -2325,5 +2326,14 @@ mod tests { expected_path.to_string_lossy(), "root_path should be derived from root_uri" ); + let workspace_folders = params + .workspace_folders + .expect("workspace folders should be set"); + + let expected_workspace_folders = vec![WorkspaceFolder { + uri: project_uri, + name: "my project".to_string(), + }]; + assert_eq!(workspace_folders, expected_workspace_folders); } } diff --git a/crates/lsp_locations/Cargo.toml b/crates/lsp_locations/Cargo.toml new file mode 100644 index 00000000000000..2559e571e80c33 --- /dev/null +++ b/crates/lsp_locations/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "lsp_locations" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/lsp_locations.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +editor.workspace = true +file_icons.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +picker.workspace = true +picker_preview.workspace = true +project.workspace = true +settings.workspace = true +text.workspace = true +theme_settings.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +editor = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +theme = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/lsp_locations/LICENSE-GPL b/crates/lsp_locations/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /dev/null +++ b/crates/lsp_locations/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/lsp_locations/src/lsp_locations.rs b/crates/lsp_locations/src/lsp_locations.rs new file mode 100644 index 00000000000000..90f29ae7bc0e4d --- /dev/null +++ b/crates/lsp_locations/src/lsp_locations.rs @@ -0,0 +1,972 @@ +use std::ops::Range; +use std::sync::Arc; + +use collections::HashMap; +use editor::actions::{FindAllReferences, GoToDefinition, GoToImplementation}; +use editor::{Editor, EditorSettings, GotoDefinitionKind, OpenResultsIn}; +use file_icons::FileIcons; +use fuzzy::StringMatchCandidate; +use gpui::{ + AnyElement, App, AppContext, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity, + prelude::*, +}; +use language::{Buffer, HighlightId, LanguageAwareStyling}; +use picker::{Picker, PickerDelegate}; +use project::{Location, Project, ProjectPath}; +use settings::{GoToDefinitionFallback, Settings as _}; +use text::{Anchor, Point}; +use theme_settings::ThemeSettings; +use ui::{Divider, FluentBuilder}; +use ui::{ListItem, ListItemSpacing, prelude::*}; +use util::ResultExt as _; +use workspace::item::ItemSettings; +use workspace::notifications::NotificationId; +use workspace::{ModalView, Toast, Workspace}; + +pub fn init(cx: &mut App) { + cx.observe_new(register).detach(); +} + +/// Registers handlers for the navigation actions on each full editor. When the +/// action resolves to [`OpenResultsIn::Picker`], we open the filterable picker; +/// otherwise we `cx.propagate()` so the editor's own handler runs and builds a +/// multibuffer. +fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context) { + if !editor.mode().is_full() { + return; + } + let handle = cx.entity().downgrade(); + editor + .register_action({ + let handle = handle.clone(); + move |action: &GoToDefinition, window, cx| { + handle_nav_action( + action.open_results_in, + LspPickerKind::Definition, + &handle, + window, + cx, + ); + } + }) + .detach(); + editor + .register_action({ + let handle = handle.clone(); + move |action: &GoToImplementation, window, cx| { + handle_nav_action( + action.open_results_in, + LspPickerKind::Implementation, + &handle, + window, + cx, + ); + } + }) + .detach(); + editor + .register_action(move |action: &FindAllReferences, window, cx| { + handle_nav_action( + action.open_results_in, + LspPickerKind::References, + &handle, + window, + cx, + ); + }) + .detach(); +} + +/// Either opens the picker for the editor, or propagates the action so the +/// editor's built-in (multibuffer) handler runs. A `None` argument falls back to +/// the `lsp_results_location` setting. +fn handle_nav_action( + open_results_in: Option, + kind: LspPickerKind, + editor: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let open_results_in = + open_results_in.unwrap_or_else(|| EditorSettings::get_global(cx).lsp_results_location); + if open_results_in != OpenResultsIn::Picker { + cx.propagate(); + return; + } + LspLocationsPicker::open_for_editor(kind, editor.clone(), window, cx); +} + +/// Runs the LSP query for `kind` and returns the raw locations. Returns `None` +/// (and reports any error) when there is nothing to query, so the caller stops +/// without an empty-results toast. Deduplication and dropping fileless results +/// happen later in [`build_location_matches`]. +async fn run_picker_query( + kind: LspPickerKind, + editor: &WeakEntity, + workspace: &WeakEntity, + project: &Entity, + cx: &mut AsyncWindowContext, +) -> Option> { + let query = editor + .update(cx, |editor, cx| kind.run_query(editor, project, cx)) + .ok() + .flatten()?; + match query.await { + Ok(locations) => Some(locations), + Err(error) => { + log::error!("LSP {kind:?} query failed: {error:#}"); + workspace + .update(cx, |workspace, cx| workspace.show_error(error, cx)) + .log_err(); + None + } + } +} + +/// Runs the query for `kind` and builds the displayable, deduped matches. +async fn run_picker_matches( + kind: LspPickerKind, + editor: &WeakEntity, + workspace: &WeakEntity, + project: &Entity, + cx: &mut AsyncWindowContext, +) -> Option> { + let locations = run_picker_query(kind, editor, workspace, project, cx).await?; + editor + .update(cx, |_, cx| build_location_matches(&locations, cx)) + .ok() +} + +fn show_no_results_toast( + workspace: &WeakEntity, + kind: LspPickerKind, + cx: &mut AsyncWindowContext, +) { + workspace + .update(cx, |workspace, cx| { + struct NoLspResults; + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + kind.empty_message(), + ) + .autohide(), + cx, + ); + }) + .log_err(); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LspPickerKind { + References, + Definition, + Implementation, +} + +impl LspPickerKind { + fn placeholder(self) -> &'static str { + match self { + LspPickerKind::References => "Filter references…", + LspPickerKind::Definition => "Filter definitions…", + LspPickerKind::Implementation => "Filter implementations…", + } + } + + /// Message shown when the query produces no results, so the command does not + /// appear to silently do nothing. + fn empty_message(self) -> &'static str { + match self { + LspPickerKind::References => "No references found", + LspPickerKind::Definition => "No definitions found", + LspPickerKind::Implementation => "No implementations found", + } + } + + /// Runs the query for this kind against the active editor, returning the raw + /// locations to populate the picker. + fn run_query( + self, + editor: &mut Editor, + project: &Entity, + cx: &mut Context, + ) -> Option>>> { + match self { + LspPickerKind::References => editor.find_all_references_locations(project, cx), + LspPickerKind::Definition => { + editor.definition_locations_of_kind(GotoDefinitionKind::Symbol, cx) + } + LspPickerKind::Implementation => { + editor.definition_locations_of_kind(GotoDefinitionKind::Implementation, cx) + } + } + } +} + +pub struct LspLocationsPicker { + picker: Entity>, + _subscription: Subscription, +} + +impl LspLocationsPicker { + fn open_for_editor( + kind: LspPickerKind, + editor: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + let Some(editor) = editor.upgrade() else { + return; + }; + let Some(workspace) = editor.read(cx).workspace() else { + return; + }; + workspace.update(cx, |workspace, cx| { + Self::open(kind, editor, workspace, window, cx); + }); + } + + /// Opens the picker for `kind`: runs a fresh LSP query and shows the + /// results. An empty definitions query falls back to references when the + /// `go_to_definition_fallback` setting calls for it, matching + /// [`Editor::go_to_definition`]. + fn open( + kind: LspPickerKind, + editor: Entity, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + let project = workspace.project().clone(); + let fallback = EditorSettings::get_global(cx).go_to_definition_fallback; + let editor = editor.downgrade(); + cx.spawn_in(window, async move |workspace, cx| { + // The kind the user invoked, kept for user-facing messages even if + // the query below falls back to references. + let invoked_kind = kind; + let mut kind = kind; + + // Count on the built matches (not raw locations): they are deduped by + // range and exclude fileless results, so a single distinct result + // jumps directly and a fileless-only result reports "no results" + // instead of opening a blank picker. + let Some(mut matches) = + run_picker_matches(kind, &editor, &workspace, &project, cx).await + else { + return; + }; + + if matches.is_empty() + && kind == LspPickerKind::Definition + && fallback == GoToDefinitionFallback::FindAllReferences + { + kind = LspPickerKind::References; + let Some(references) = + run_picker_matches(kind, &editor, &workspace, &project, cx).await + else { + return; + }; + matches = references; + } + + if matches.is_empty() { + show_no_results_toast(&workspace, invoked_kind, cx); + return; + } + + if matches.len() == 1 { + if let Some(location_match) = matches.into_iter().next() { + let location = Location { + buffer: location_match.buffer, + range: location_match.anchor_range, + }; + if let Ok(task) = editor.update_in(cx, |editor, window, cx| { + editor.open_location(location, false, window, cx) + }) { + task.await.log_err(); + } + } + return; + } + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + Self::new(kind, matches, project, editor, window, cx) + }); + }) + .log_err(); + }) + .detach(); + } + + fn new( + kind: LspPickerKind, + matches: Vec, + project: Entity, + editor: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let preview = picker_preview::editor_preview(project.clone(), window, cx); + let delegate = LspLocationsDelegate::new(kind, matches, project, editor); + let picker = cx.new(|cx| Picker::list_with_preview(delegate, preview, window, cx)); + let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { + cx.emit(DismissEvent); + }); + Self { + picker, + _subscription: subscription, + } + } +} + +impl ModalView for LspLocationsPicker {} + +impl EventEmitter for LspLocationsPicker {} + +impl Focusable for LspLocationsPicker { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl Render for LspLocationsPicker { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + v_flex().child(self.picker.clone()) + } +} + +struct LocationMatch { + path: ProjectPath, + buffer: Entity, + anchor_range: Range, + range: Range, + display_text: String, + syntax_highlights: Vec<(Range, HighlightId)>, + match_range: Range, + line_number: u32, +} + +/// A row in the grouped display list: a non-selectable file header, a match, or +/// a separator between file groups. `selected_index` indexes into this list. +enum Entry { + Header(ProjectPath), + Match(usize), + Separator, +} + +struct LspLocationsDelegate { + kind: LspPickerKind, + project: Entity, + editor: WeakEntity, + all_matches: Vec, + candidates: Arc<[StringMatchCandidate]>, + matches: Vec, + entries: Vec, + selected_index: usize, + max_line_number: u32, +} + +impl LspLocationsDelegate { + fn new( + kind: LspPickerKind, + all_matches: Vec, + project: Entity, + editor: WeakEntity, + ) -> Self { + // Match against the line text and the file path, mirroring the fuzzy + // matching every other Zed picker uses. + let candidates = all_matches + .iter() + .enumerate() + .map(|(index, location_match)| { + StringMatchCandidate::new( + index, + &format!( + "{} {}", + location_match.display_text, + location_match.path.path.as_unix_str() + ), + ) + }) + .collect(); + let matches = (0..all_matches.len()).collect(); + let mut this = Self { + kind, + project, + editor, + all_matches, + candidates, + matches, + entries: Vec::new(), + selected_index: 0, + max_line_number: 0, + }; + this.rebuild_entries(); + this + } + + /// Rebuilds the grouped [`Self::entries`] from the filtered [`Self::matches`]: + /// one header per file, its matches, and a separator before every group + /// after the first. Selection snaps to the first selectable row. + fn rebuild_entries(&mut self) { + let mut entries = Vec::with_capacity(self.matches.len()); + let mut last_path: Option<&ProjectPath> = None; + let mut max_line_number = 0; + for &match_index in &self.matches { + let location_match = &self.all_matches[match_index]; + if last_path != Some(&location_match.path) { + if last_path.is_some() { + entries.push(Entry::Separator); + } + entries.push(Entry::Header(location_match.path.clone())); + last_path = Some(&location_match.path); + } + max_line_number = max_line_number.max(location_match.line_number); + entries.push(Entry::Match(match_index)); + } + self.entries = entries; + self.max_line_number = max_line_number; + self.selected_index = self.first_selectable_index().unwrap_or(0); + } + + fn first_selectable_index(&self) -> Option { + self.entries + .iter() + .position(|entry| matches!(entry, Entry::Match(_))) + } + + fn selected_location_match(&self) -> Option<&LocationMatch> { + match self.entries.get(self.selected_index)? { + Entry::Match(match_index) => self.all_matches.get(*match_index), + Entry::Header(_) | Entry::Separator => None, + } + } + + fn open_selected(&mut self, split: bool, window: &mut Window, cx: &mut Context>) { + let Some(location_match) = self.selected_location_match() else { + return; + }; + let location = Location { + buffer: location_match.buffer.clone(), + range: location_match.anchor_range.clone(), + }; + let Some(editor) = self.editor.upgrade() else { + return; + }; + editor + .update(cx, |editor, cx| { + editor.open_location(location, split, window, cx) + }) + .detach_and_log_err(cx); + cx.emit(DismissEvent); + } +} + +fn build_location_matches(locations: &[Location], cx: &App) -> Vec { + use gpui::EntityId; + let mut snapshots: HashMap = HashMap::default(); + let mut matches = Vec::with_capacity(locations.len()); + + for location in locations { + let snapshot = snapshots + .entry(location.buffer.entity_id()) + .or_insert_with(|| location.buffer.read(cx).snapshot()); + + let Some(file) = snapshot.file() else { + continue; + }; + let path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }; + + let start_offset: usize = snapshot.summary_for_anchor(&location.range.start); + let end_offset: usize = snapshot.summary_for_anchor(&location.range.end); + let row = snapshot.offset_to_point(start_offset).row; + let line_start = snapshot.point_to_offset(Point::new(row, 0)); + let line_end = snapshot.point_to_offset(Point::new(row, snapshot.line_len(row))); + let full_line: String = snapshot.text_for_range(line_start..line_end).collect(); + + // The row shows the line with leading indentation trimmed. Offsets below + // are relative to that displayed text. + let display_text = full_line.trim_start().to_string(); + let visible_start = line_end.saturating_sub(display_text.len()); + let visible_end = line_end; + + // Precompute syntax highlights for the displayed text so rendering a row + // never re-snapshots the buffer or re-runs highlighting. + let mut syntax_highlights = Vec::new(); + let mut offset = 0; + for chunk in snapshot.chunks( + visible_start..visible_end, + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ) { + let chunk_len = chunk.text.len(); + if let Some(id) = chunk.syntax_highlight_id { + syntax_highlights.push((offset..offset + chunk_len, id)); + } + offset += chunk_len; + } + + // The match span, clamped into the displayed text. `clamp` bounds each + // endpoint to the line; `min`/`max` then keep the range well-ordered even + // for a malformed/inverted LSP range (clamping alone preserves bounds but + // not `start <= end`). + let clamped_start = start_offset.clamp(visible_start, visible_end) - visible_start; + let clamped_end = end_offset.clamp(visible_start, visible_end) - visible_start; + let match_range = clamped_start.min(clamped_end)..clamped_start.max(clamped_end); + + matches.push(LocationMatch { + path, + buffer: location.buffer.clone(), + anchor_range: location.range.clone(), + range: start_offset..end_offset, + display_text, + syntax_highlights, + match_range, + line_number: row + 1, + }); + } + + // Group by file and order by position so the grouped display list is stable, + // then drop exact-duplicate ranges a server may report more than once. + matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.range.start.cmp(&b.range.start))); + matches.dedup_by(|a, b| a.path == b.path && a.range == b.range); + matches +} + +impl PickerDelegate for LspLocationsDelegate { + type ListItem = AnyElement; + + fn name() -> &'static str { + "lsp locations picker" + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc { + self.kind.placeholder().into() + } + + fn match_count(&self) -> usize { + self.entries.len() + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + matches!(self.entries.get(ix), Some(Entry::Match(_))) + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn select_on_hover(&self) -> bool { + false + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let query = query.trim().to_owned(); + let candidates = self.candidates.clone(); + cx.spawn(async move |picker, cx| { + let matches = if query.is_empty() { + (0..candidates.len()).collect() + } else { + let string_matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + candidates.len(), + &Default::default(), + cx.background_executor().clone(), + ) + .await; + let mut indices = string_matches + .into_iter() + .map(|string_match| string_match.candidate_id) + .collect::>(); + // Restore the file-grouped, positional order (fuzzy returns by score). + indices.sort_unstable(); + indices + }; + picker + .update(cx, |picker, cx| { + picker.delegate.matches = matches; + picker.delegate.rebuild_entries(); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + self.open_selected(secondary, window, cx); + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { + let location_match = self.selected_location_match()?; + Some(picker::PreviewUpdate::from_buffer( + location_match.buffer.clone(), + picker::MatchLocation { + anchor_range: location_match.anchor_range.clone(), + range: location_match.range.clone(), + }, + )) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.entries.get(ix)? { + Entry::Separator => Some( + div() + .py(DynamicSpacing::Base04.rems(cx)) + .child(Divider::horizontal()) + .into_any_element(), + ), + Entry::Header(path) => { + let path_style = self.project.read(cx).path_style(cx); + let file_name = path + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_default(); + let directory = path + .path + .parent() + .map(|parent| parent.display(path_style)) + .map(SharedString::new) + .unwrap_or_default(); + let file_icon = ItemSettings::get_global(cx) + .file_icons + .then(|| FileIcons::get_icon(path.path.as_std_path(), cx)) + .flatten() + .map(|icon| { + Icon::from_path(icon) + .color(Color::Muted) + .size(IconSize::Small) + }); + Some( + h_flex() + .w_full() + .min_w_0() + .px(DynamicSpacing::Base06.rems(cx)) + .py_1() + .gap_1p5() + .children(file_icon) + .child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when(!directory.is_empty(), |this| { + this.child( + Label::new(directory) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ) + }), + ) + .into_any_element(), + ) + } + Entry::Match(match_index) => { + let location_match = self.all_matches.get(*match_index)?; + Some( + ListItem::new(ix) + .spacing(ListItemSpacing::Sparse) + .inset(true) + .toggle_state(selected) + .child( + h_flex() + .w_full() + .min_w_0() + .gap_2p5() + .text_sm() + .child( + h_flex() + .w(rems( + (self.max_line_number.max(1).ilog10() + 1) as f32 * 0.5, + )) + .justify_end() + .child( + Label::new(location_match.line_number.to_string()) + .color(Color::Custom( + cx.theme().colors().text_muted.opacity(0.5), + )), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(render_matched_line(location_match, cx)), + ), + ) + .into_any_element(), + ) + } + } + } +} + +/// Renders the precomputed displayed line, resolving the stored syntax highlight +/// ids against the current theme and overlaying the match with a highlighted +/// background and bold weight. +fn render_matched_line(location_match: &LocationMatch, cx: &App) -> StyledText { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().text, + 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(1.), + ..Default::default() + }; + + let syntax_theme = cx.theme().syntax(); + let syntax_highlights = location_match + .syntax_highlights + .iter() + .filter_map(|(range, id)| Some((range.clone(), syntax_theme.get(*id).copied()?))) + .collect::>(); + + let match_style = HighlightStyle { + background_color: Some(cx.theme().colors().search_match_background), + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }; + let match_highlight = (location_match.match_range.clone(), match_style); + + let highlights = gpui::combine_highlights(syntax_highlights, [match_highlight]); + StyledText::new(location_match.display_text.clone()) + .with_default_highlights(&text_style, highlights) +} + +#[cfg(test)] +mod tests { + use super::*; + use editor::test::editor_lsp_test_context::EditorLspTestContext; + use gpui::TestAppContext; + use indoc::indoc; + + async fn rust_cx( + capabilities: lsp::ServerCapabilities, + cx: &mut TestAppContext, + ) -> EditorLspTestContext { + EditorLspTestContext::new_rust(capabilities, cx).await + } + + fn open(cx: &mut EditorLspTestContext, kind: LspPickerKind) { + let editor = cx.editor.clone(); + let workspace = cx.workspace.clone(); + cx.update(|window, cx| { + workspace.update(cx, |workspace, cx| { + LspLocationsPicker::open(kind, editor, workspace, window, cx); + }); + }); + cx.run_until_parked(); + } + + fn active_picker(cx: &mut EditorLspTestContext) -> Option> { + let workspace = cx.workspace.clone(); + cx.update(|_window, cx| workspace.read(cx).active_modal::(cx)) + } + + fn references(uri: lsp::Uri, ranges: &[(u32, u32, u32)]) -> Vec { + ranges + .iter() + .map(|&(row, start, end)| lsp::Location { + uri: uri.clone(), + range: lsp::Range::new( + lsp::Position::new(row, start), + lsp::Position::new(row, end), + ), + }) + .collect() + } + + const SOURCE: &str = indoc! {r#" + fn main() { + let aˇbc = 123; + let xyz = abc; + } + "#}; + + #[gpui::test] + async fn test_multiple_references_open_picker(cx: &mut TestAppContext) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(SOURCE); + cx.lsp + .set_request_handler::(async move |params, _| { + let uri = params.text_document_position.text_document.uri; + Ok(Some(references(uri, &[(1, 8, 11), (2, 14, 17)]))) + }); + + open(&mut cx, LspPickerKind::References); + + assert!( + active_picker(&mut cx).is_some(), + "multiple references should open the picker" + ); + } + + #[gpui::test] + async fn test_single_result_jumps_without_picker(cx: &mut TestAppContext) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(SOURCE); + cx.lsp + .set_request_handler::(async move |params, _| { + let uri = params.text_document_position.text_document.uri; + Ok(Some(references(uri, &[(2, 14, 17)]))) + }); + + open(&mut cx, LspPickerKind::References); + + assert!( + active_picker(&mut cx).is_none(), + "a single result should jump directly instead of opening the picker" + ); + // The lone result at row 2 should be selected directly, moving the + // cursor off its starting position on row 1. + cx.assert_editor_state(indoc! {r#" + fn main() { + let abc = 123; + let xyz = «abcˇ»; + } + "#}); + } + + #[gpui::test] + async fn test_no_results_does_not_open_picker(cx: &mut TestAppContext) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(SOURCE); + cx.lsp + .set_request_handler::(async move |_params, _| { + Ok(Some(Vec::new())) + }); + + open(&mut cx, LspPickerKind::References); + + assert!( + active_picker(&mut cx).is_none(), + "an empty result should not open the picker" + ); + } + + #[gpui::test] + async fn test_definition_falls_back_to_references_picker(cx: &mut TestAppContext) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(SOURCE); + cx.lsp + .set_request_handler::(async move |_params, _| { + Ok(None) + }); + cx.lsp + .set_request_handler::(async move |params, _| { + let uri = params.text_document_position.text_document.uri; + Ok(Some(references(uri, &[(1, 8, 11), (2, 14, 17)]))) + }); + + open(&mut cx, LspPickerKind::Definition); + + assert!( + active_picker(&mut cx).is_some(), + "an empty definition query should fall back to the references picker" + ); + } + + #[gpui::test] + async fn test_fuzzy_filter_matches_subsequence(cx: &mut TestAppContext) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(SOURCE); + cx.lsp + .set_request_handler::(async move |params, _| { + let uri = params.text_document_position.text_document.uri; + Ok(Some(references(uri, &[(1, 8, 11), (2, 14, 17)]))) + }); + + open(&mut cx, LspPickerKind::References); + let modal = active_picker(&mut cx).expect("multiple references should open the picker"); + let picker = cx.update(|_window, cx| modal.read(cx).picker.clone()); + + let matches = |cx: &mut EditorLspTestContext, query: &str| -> usize { + cx.update(|window, cx| { + picker.update(cx, |picker, cx| picker.set_query(query, window, cx)); + }); + cx.run_until_parked(); + cx.update(|_window, cx| picker.read(cx).delegate.matches.len()) + }; + + // "lx" is a subsequence of "let xyz" but not a substring of either line, + // so it only matches with fuzzy matching. + assert_eq!(matches(&mut cx, "lx"), 1); + assert_eq!(matches(&mut cx, "zzzz"), 0); + assert_eq!(matches(&mut cx, ""), 2); + } +} diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index 7443803346e2bc..3db8d84b73336f 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -46,6 +46,7 @@ env_logger.workspace = true fs = {workspace = true, features = ["test-support"]} gpui = { workspace = true, features = ["test-support"] } gpui_platform = { workspace = true, features = ["wayland", "x11"] } +image.workspace = true language = { workspace = true, features = ["test-support"] } languages = { workspace = true, features = ["load-grammars"] } node_runtime.workspace = true diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 25e869625fb5ed..00b73f1e44e060 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -391,9 +391,7 @@ impl MarkdownElement { boundaries.sort_unstable(); boundaries.dedup(); - for segment in boundaries.windows(2) { - let start = segment[0]; - let end = segment[1]; + for &[start, end] in boundaries.array_windows::<2>() { if start >= end { continue; } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 89266065a1c040..f9862ee9ae102e 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -2,6 +2,7 @@ pub mod html; mod mermaid; pub mod parser; mod path_range; +mod selection; use base64::Engine as _; use futures::FutureExt as _; @@ -21,6 +22,7 @@ use theme_settings::ThemeSettings; use util::maybe; use std::borrow::Cow; +use std::cell::Cell; use std::collections::BTreeMap; use std::mem; use std::ops::Range; @@ -36,7 +38,7 @@ use gpui::{ ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledImage, StyledText, Subscription, Task, TextAlign, TextLayout, TextRun, - TextStyle, TextStyleRefinement, WrappedLineLayout, actions, img, point, quad, + TextStyle, TextStyleRefinement, WrappedLineLayout, actions, canvas, img, point, quad, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; @@ -56,6 +58,7 @@ use crate::parser::CodeBlockKind; /// If the callback returns `None`, the default link style will be used. type LinkStyleCallback = Rc Option>; pub type CodeSpanLinkCallback = Arc Option + 'static>; +type UrlHoverCallback = Rc, &mut Window, &mut App)>; type SourceClickCallback = Box bool>; type CheckboxToggleCallback = Rc, bool, &mut Window, &mut App)>; @@ -808,12 +811,15 @@ impl Markdown { output.into() } - pub fn selected_text(&self) -> Option { + pub fn has_selection(&self) -> bool { + self.selection.end > self.selection.start + } + + pub fn selected_source(&self) -> Option<&str> { if self.selection.end <= self.selection.start { - None - } else { - Some(self.source[self.selection.start..self.selection.end].to_string()) + return None; } + self.source.get(self.selection.start..self.selection.end) } pub fn set_search_highlights( @@ -873,7 +879,9 @@ impl Markdown { if self.selection.end <= self.selection.start { return; } - let text = self.source[self.selection.start..self.selection.end].to_string(); + let text = self + .parsed_markdown + .rebalanced_markdown_for_selection(self.selection.start..self.selection.end); cx.write_to_clipboard(ClipboardItem::new_string(text)); } @@ -884,8 +892,10 @@ impl Markdown { ) { let range = self.selection.start..self.selection.end; if range.end > range.start { - self.context_menu_selected_markdown = - Some(SharedString::new(&self.source[range.clone()])); + self.context_menu_selected_markdown = Some(SharedString::new( + self.parsed_markdown + .rebalanced_markdown_for_selection(range.clone()), + )); self.context_menu_selected_text = rendered_text .map(|text| text.text_for_range(range)) .map(SharedString::new) @@ -910,8 +920,9 @@ impl Markdown { self.context_menu_selected_text.as_ref() } - /// Returns the raw markdown source that was selected when the most recent - /// context menu invocation happened. + /// Returns the markdown that was selected when the most recent context + /// menu invocation happened, rebalanced via + /// [`ParsedMarkdown::rebalanced_markdown_for_selection`]. pub fn context_menu_selected_markdown(&self) -> Option<&SharedString> { self.context_menu_selected_markdown.as_ref() } @@ -1207,6 +1218,21 @@ impl ParsedMarkdown { Some(partition.saturating_sub(1)) } + + /// Extracts the markdown source for a selection, rebalancing inline + /// delimiters (`**`, backticks, link syntax, etc.) so partial selections of + /// styled spans stay well-formed. + /// + /// With an exception of a single inline code span, which is returned as plain + /// text, since copying a command or identifier is the dominant use case there. + pub fn rebalanced_markdown_for_selection(&self, selection: Range) -> String { + selection::rebalanced_markdown_for_selection( + &self.source, + &self.events, + &self.root_block_starts, + selection, + ) + } } pub enum AutoscrollBehavior { @@ -1221,7 +1247,8 @@ pub struct MarkdownElement { markdown: Entity, style: MarkdownStyle, code_block_renderer: CodeBlockRenderer, - on_url_click: Option>, + on_url_click: Option>, + on_url_hover: Option, code_span_link: Option, on_source_click: Option, on_checkbox_toggle: Option, @@ -1241,6 +1268,7 @@ impl MarkdownElement { border: false, }, on_url_click: None, + on_url_hover: None, code_span_link: None, on_source_click: None, on_checkbox_toggle: None, @@ -1280,7 +1308,15 @@ impl MarkdownElement { mut self, handler: impl Fn(SharedString, &mut Window, &mut App) + 'static, ) -> Self { - self.on_url_click = Some(Box::new(handler)); + self.on_url_click = Some(Rc::new(handler)); + self + } + + pub fn on_url_hover( + mut self, + handler: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_url_hover = Some(Rc::new(handler)); self } @@ -1378,18 +1414,71 @@ impl MarkdownElement { width: Option, height: Option, ) { - let image_element = div().min_w_0().child( - img(source) - .id(("markdown-image", range.start)) - .min_w_0() - .max_w_full() - .rounded_md() - .mr_1() - .mb_1() - .when_some(height, |this, height| this.h(height)) - .when_some(width, |this, width| this.w(width)) - .with_fallback(move || image_fallback_element(dest_url.clone(), alt_text.clone())), - ); + let enclosing_link_url = (builder.link_depth > 0) + .then(|| builder.rendered_links.last()) + .flatten() + .map(|link| link.destination_url.clone()); + let fallback_opens_image_url = enclosing_link_url.is_none(); + + let image_element = { + let wrapper = div().id(("markdown-image-link", range.start)).min_w_0(); + let wrapper = if !self.style.prevent_mouse_interaction + && let Some(url) = enclosing_link_url + { + let click_url = url.clone(); + let markdown = self.markdown.clone(); + let url_click = self.on_url_click.clone(); + let bounds = Rc::new(Cell::new(None)); + builder.push_image_link(url.clone(), bounds.clone()); + wrapper + .relative() + .cursor_pointer() + .child( + canvas( + move |image_bounds, _window, _cx| bounds.set(Some(image_bounds)), + |_, _, _, _| {}, + ) + .size_full() + .absolute() + .top_0() + .left_0(), + ) + .on_click(move |_, window, cx| { + if let Some(ref on_url_click) = url_click { + on_url_click(click_url.clone(), window, cx); + } else { + cx.open_url(&click_url); + } + }) + .capture_any_mouse_down(move |event, _window, cx| { + if event.button == MouseButton::Right { + markdown.update(cx, |md, _| { + md.capture_for_context_menu(Some(url.clone()), None) + }); + } + }) + } else { + wrapper + }; + wrapper.child( + img(source) + .id(("markdown-image", range.start)) + .min_w_0() + .max_w_full() + .rounded_md() + .mr_1() + .mb_1() + .when_some(height, |this, height| this.h(height)) + .when_some(width, |this, width| this.w(width)) + .with_fallback(move || { + image_fallback_element( + dest_url.clone(), + alt_text.clone(), + fallback_opens_image_url, + ) + }), + ) + }; builder.push_image_child(image_element); } @@ -1727,15 +1816,18 @@ impl MarkdownElement { let is_hovering_clickable = hitbox.is_hovered(window) && !self.markdown.read(cx).selection.pending - && rendered_text - .source_index_for_position(window.mouse_position()) - .ok() - .is_some_and(|source_index| { - rendered_text.link_for_source_index(source_index).is_some() - || rendered_text - .footnote_ref_for_source_index(source_index) - .is_some() - }); + && (rendered_text + .image_link_for_position(window.mouse_position()) + .is_some() + || rendered_text + .source_index_for_position(window.mouse_position()) + .ok() + .is_some_and(|source_index| { + rendered_text.link_for_source_index(source_index).is_some() + || rendered_text + .footnote_ref_for_source_index(source_index) + .is_some() + })); if is_hovering_clickable { window.set_cursor_style(CursorStyle::PointingHand, hitbox); @@ -1744,6 +1836,7 @@ impl MarkdownElement { } let on_open_url = self.on_url_click.take(); + let on_url_hover = self.on_url_hover.take(); let on_source_click = self.on_source_click.take(); self.on_mouse_event(window, cx, { @@ -1872,16 +1965,30 @@ impl MarkdownElement { markdown.autoscroll_request = Some(source_index); cx.notify(); } else { - let is_hovering_clickable = hitbox.is_hovered(window) - && rendered_text - .source_index_for_position(event.position) - .ok() - .is_some_and(|source_index| { - rendered_text.link_for_source_index(source_index).is_some() - || rendered_text - .footnote_ref_for_source_index(source_index) - .is_some() - }); + let is_hitbox_hovered = hitbox.is_hovered(window); + let source_index = is_hitbox_hovered + .then(|| rendered_text.source_index_for_position(event.position).ok()) + .flatten(); + let hovered_url = is_hitbox_hovered + .then(|| rendered_text.image_link_for_position(event.position)) + .flatten() + .map(|image| image.destination_url.clone()) + .or_else(|| { + source_index + .and_then(|source_index| { + rendered_text.link_for_source_index(source_index) + }) + .map(|link| link.destination_url.clone()) + }); + let is_hovering_clickable = hovered_url.is_some() + || source_index.is_some_and(|source_index| { + rendered_text + .footnote_ref_for_source_index(source_index) + .is_some() + }); + if let Some(on_url_hover) = on_url_hover.as_ref() { + on_url_hover(hovered_url, window, cx); + } if is_hovering_clickable != was_hovering_clickable { cx.notify(); } @@ -2795,7 +2902,11 @@ fn collect_image_alt_text( } } -fn image_fallback_element(dest_url: SharedString, alt_text: Option) -> AnyElement { +fn image_fallback_element( + dest_url: SharedString, + alt_text: Option, + open_image_url_on_click: bool, +) -> AnyElement { let link_label = alt_text .filter(|alt| !alt.is_empty()) .unwrap_or_else(|| dest_url.clone()); @@ -2804,13 +2915,15 @@ fn image_fallback_element(dest_url: SharedString, alt_text: Option div() .id("image-fallback") - .cursor_pointer() .min_w_0() .child(Label::new(label).color(Color::Warning).underline()) .tooltip(Tooltip::text( "Image failed to load. Open `zed: log` for more details.", )) - .on_click(move |_, _, cx| cx.open_url(&dest_url)) + .when(open_image_url_on_click, |this| { + this.cursor_pointer() + .on_click(move |_, _, cx| cx.open_url(&dest_url)) + }) .into_any_element() } @@ -3054,6 +3167,7 @@ struct MarkdownElementBuilder { rendered_lines: Vec, pending_line: PendingLine, rendered_links: Vec, + rendered_image_links: Vec, rendered_footnote_refs: Vec, current_source_index: usize, html_comment: bool, @@ -3113,6 +3227,7 @@ impl MarkdownElementBuilder { rendered_lines: Vec::new(), pending_line: PendingLine::default(), rendered_links: Vec::new(), + rendered_image_links: Vec::new(), rendered_footnote_refs: Vec::new(), current_source_index: 0, html_comment: false, @@ -3303,6 +3418,17 @@ impl MarkdownElementBuilder { }); } + fn push_image_link( + &mut self, + destination_url: SharedString, + bounds: Rc>>>, + ) { + self.rendered_image_links.push(RenderedImageLink { + bounds, + destination_url, + }); + } + fn push_footnote_ref(&mut self, label: SharedString, source_range: Range) { self.rendered_footnote_refs.push(RenderedFootnoteRef { source_range, @@ -3468,6 +3594,7 @@ impl MarkdownElementBuilder { text: RenderedText { lines: self.rendered_lines.into(), links: self.rendered_links.into(), + image_links: self.rendered_image_links.into(), footnote_refs: self.rendered_footnote_refs.into(), }, } @@ -3667,6 +3794,7 @@ pub struct RenderedMarkdown { struct RenderedText { lines: Rc<[RenderedLine]>, links: Rc<[RenderedLink]>, + image_links: Rc<[RenderedImageLink]>, footnote_refs: Rc<[RenderedFootnoteRef]>, } @@ -3683,6 +3811,14 @@ struct RenderedLink { destination_url: SharedString, } +#[derive(Clone)] +struct RenderedImageLink { + // Populated once the image's `canvas` overlay is painted; images aren't part of the + // text layout, so their hit-test region can't be derived from a source range. + bounds: Rc>>>, + destination_url: SharedString, +} + #[derive(Debug, Clone, Eq, PartialEq)] struct RenderedFootnoteRef { source_range: Range, @@ -3996,6 +4132,15 @@ impl RenderedText { .find(|link| link.source_range.contains(&source_index)) } + fn image_link_for_position(&self, position: Point) -> Option<&RenderedImageLink> { + self.image_links.iter().find(|image| { + image + .bounds + .get() + .is_some_and(|bounds| bounds.contains(&position)) + }) + } + fn footnote_ref_for_source_index(&self, source_index: usize) -> Option<&RenderedFootnoteRef> { self.footnote_refs .iter() @@ -4008,6 +4153,7 @@ mod tests { use super::*; use gpui::{RenderImage, TestAppContext, UpdateGlobal, size}; use language::{Language, LanguageConfig, LanguageMatcher}; + use std::cell::RefCell; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -5219,6 +5365,101 @@ mod tests { }); } + #[gpui::test] + fn test_url_hover_callback(cx: &mut TestAppContext) { + struct HoverTestView { + markdown: Entity, + hovered_urls: Rc>>>, + } + + impl Render for HoverTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let hovered_urls = self.hovered_urls.clone(); + div().size_full().child( + MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default()) + .on_url_hover(move |url, _, _| { + hovered_urls.borrow_mut().push(url); + }), + ) + } + } + + ensure_theme_initialized(cx); + let hovered_urls = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let hovered_urls = hovered_urls.clone(); + move |_, cx| HoverTestView { + markdown: cx + .new(|cx| Markdown::new("[link](https://example.com)".into(), None, None, cx)), + hovered_urls, + } + }); + cx.run_until_parked(); + + cx.simulate_mouse_move(point(px(8.), px(8.)), None, gpui::Modifiers::default()); + assert_eq!( + hovered_urls.borrow().last().cloned().flatten().as_deref(), + Some("https://example.com") + ); + + cx.simulate_mouse_move(point(px(500.), px(500.)), None, gpui::Modifiers::default()); + assert_eq!(hovered_urls.borrow().last(), Some(&None)); + } + + #[gpui::test] + fn test_url_hover_callback_for_linked_image(cx: &mut TestAppContext) { + struct HoverTestView { + markdown: Entity, + hovered_urls: Rc>>>, + } + + impl Render for HoverTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let hovered_urls = self.hovered_urls.clone(); + div().size_full().child( + MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default()) + .image_resolver(|_| Some(loaded_image_source())) + .on_url_hover(move |url, _, _| { + hovered_urls.borrow_mut().push(url); + }), + ) + } + } + + ensure_theme_initialized(cx); + let hovered_urls = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let hovered_urls = hovered_urls.clone(); + move |_, cx| HoverTestView { + markdown: cx.new(|cx| { + Markdown::new( + "[![badge](https://example.com/badge.png)](https://example.com)".into(), + None, + None, + cx, + ) + }), + hovered_urls, + } + }); + cx.run_until_parked(); + + cx.simulate_mouse_move(point(px(4.), px(4.)), None, gpui::Modifiers::default()); + assert_eq!( + hovered_urls.borrow().last().cloned().flatten().as_deref(), + Some("https://example.com") + ); + + cx.simulate_mouse_move(point(px(8.), px(8.)), None, gpui::Modifiers::default()); + assert_eq!( + hovered_urls.borrow().last().cloned().flatten().as_deref(), + Some("https://example.com") + ); + + cx.simulate_mouse_move(point(px(500.), px(500.)), None, gpui::Modifiers::default()); + assert_eq!(hovered_urls.borrow().last(), Some(&None)); + } + #[gpui::test] fn test_capture_for_context_menu(cx: &mut TestAppContext) { ensure_theme_initialized(cx); @@ -5289,6 +5530,98 @@ mod tests { }); } + fn failing_image_source() -> ImageSource { + ImageSource::Custom(Arc::new(|_, _| { + Some(Err(gpui::ImageCacheError::Asset( + "failed to load image".into(), + ))) + })) + } + + fn loaded_image_source() -> ImageSource { + let buffer = image::ImageBuffer::from_pixel(16, 16, image::Rgba([0, 0, 0, 255])); + ImageSource::Render(Arc::new(gpui::RenderImage::new(SmallVec::from_elem( + image::Frame::new(buffer), + 1, + )))) + } + + fn open_markdown_image_test_window<'a>( + source: &str, + image_source: ImageSource, + cx: &'a mut TestAppContext, + ) -> &'a mut gpui::VisualTestContext { + struct ImageTestView { + markdown: Entity, + image_source: ImageSource, + } + + impl Render for ImageTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let image_source = self.image_source.clone(); + div().size_full().child( + MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default()) + .image_resolver(move |_| Some(image_source.clone())), + ) + } + } + + ensure_theme_initialized(cx); + + let source = source.to_string(); + let (_, cx) = cx.add_window_view(|_, cx| ImageTestView { + markdown: cx.new(|cx| Markdown::new(source.into(), None, None, cx)), + image_source, + }); + cx.run_until_parked(); + cx + } + + #[gpui::test] + fn test_clicking_image_fallback_opens_image_url(cx: &mut TestAppContext) { + let cx = open_markdown_image_test_window( + "![alt text](https://example.com/image.png)", + failing_image_source(), + cx, + ); + + cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default()); + assert_eq!( + cx.opened_url(), + Some("https://example.com/image.png".to_string()) + ); + } + + #[gpui::test] + fn test_clicking_image_fallback_inside_link_opens_link_url(cx: &mut TestAppContext) { + let cx = open_markdown_image_test_window( + "[![alt text](https://example.com/image.png)](https://example.com/link)", + failing_image_source(), + cx, + ); + + cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default()); + assert_eq!( + cx.opened_url(), + Some("https://example.com/link".to_string()) + ); + } + + #[gpui::test] + fn test_clicking_loaded_image_inside_link_opens_link_url(cx: &mut TestAppContext) { + let cx = open_markdown_image_test_window( + "[![alt text](https://example.com/image.png)](https://example.com/link)", + loaded_image_source(), + cx, + ); + + cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default()); + assert_eq!( + cx.opened_url(), + Some("https://example.com/link".to_string()) + ); + } + #[track_caller] fn assert_mappings(rendered: &RenderedText, expected: Vec>) { assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch"); @@ -5399,13 +5732,13 @@ mod tests { } #[gpui::test] - fn test_editor_zoom_does_not_affect_markdown_preview(cx: &mut TestAppContext) { + fn test_ui_zoom_does_not_affect_markdown_preview(cx: &mut TestAppContext) { ensure_theme_initialized(cx); cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.buffer_font_size = Some(16.0.into()); + settings.theme.ui_font_size = Some(16.0.into()); settings.theme.markdown_preview_font_size = None; }); }); @@ -5416,11 +5749,9 @@ mod tests { let before = ThemeSettings::get_global(cx).markdown_preview_font_size(cx); assert_eq!(before, px(16.0)); - theme_settings::increase_buffer_font_size(cx); - theme_settings::increase_buffer_font_size(cx); - theme_settings::increase_buffer_font_size(cx); + theme_settings::adjust_ui_font_size(cx, |size| size + px(3.0)); - assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(19.0)); + assert_eq!(ThemeSettings::get_global(cx).ui_font_size(cx), px(19.0)); assert_eq!( ThemeSettings::get_global(cx).markdown_preview_font_size(cx), before @@ -5429,13 +5760,13 @@ mod tests { } #[gpui::test] - fn test_markdown_preview_follows_buffer_font_size_setting_when_unset(cx: &mut TestAppContext) { + fn test_markdown_preview_follows_ui_font_size_setting_when_unset(cx: &mut TestAppContext) { ensure_theme_initialized(cx); cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.buffer_font_size = Some(20.0.into()); + settings.theme.ui_font_size = Some(20.0.into()); settings.theme.markdown_preview_font_size = None; }); }); @@ -5451,7 +5782,7 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.buffer_font_size = Some(24.0.into()); + settings.theme.ui_font_size = Some(24.0.into()); }); }); }); diff --git a/crates/markdown/src/selection.rs b/crates/markdown/src/selection.rs new file mode 100644 index 00000000000000..9e7851b549bcd5 --- /dev/null +++ b/crates/markdown/src/selection.rs @@ -0,0 +1,598 @@ +use crate::parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd}; +use std::ops::Range; + +struct InlineSpan { + range: Range, + content: Range, + is_code: bool, +} + +impl InlineSpan { + fn opening<'a>(&self, source: &'a str) -> &'a str { + source + .get(self.range.start..self.content.start) + .unwrap_or("") + } + + fn closing<'a>(&self, source: &'a str) -> &'a str { + source.get(self.content.end..self.range.end).unwrap_or("") + } +} + +fn is_inline_span_tag(tag: &MarkdownTag) -> bool { + matches!( + tag, + MarkdownTag::Emphasis + | MarkdownTag::Strong + | MarkdownTag::Strikethrough + | MarkdownTag::Superscript + | MarkdownTag::Subscript + | MarkdownTag::Link { .. } + ) +} + +fn is_inline_span_tag_end(tag: &MarkdownTagEnd) -> bool { + matches!( + tag, + MarkdownTagEnd::Emphasis + | MarkdownTagEnd::Strong + | MarkdownTagEnd::Strikethrough + | MarkdownTagEnd::Superscript + | MarkdownTagEnd::Subscript + | MarkdownTagEnd::Link + ) +} + +fn inline_code_full_range(source: &str, content: &Range) -> Range { + let opening_ticks = source[..content.start] + .bytes() + .rev() + .take_while(|&byte| byte == b'`') + .count(); + let closing_ticks = source[content.end..] + .bytes() + .take_while(|&byte| byte == b'`') + .count(); + let ticks = opening_ticks.min(closing_ticks); + content.start - ticks..content.end + ticks +} + +fn collect_inline_spans(source: &str, events: &[(Range, MarkdownEvent)]) -> Vec { + fn note_child(stack: &mut [(Range, Option>)], child: &Range) { + for (_, content) in stack.iter_mut() { + match content { + Some(content) => content.end = content.end.max(child.end), + None => *content = Some(child.clone()), + } + } + } + + let mut spans = Vec::new(); + let mut stack: Vec<(Range, Option>)> = Vec::new(); + for (event_range, event) in events { + match event { + MarkdownEvent::Start(tag) if is_inline_span_tag(tag) => { + note_child(&mut stack, event_range); + stack.push((event_range.clone(), None)); + } + MarkdownEvent::End(tag) if is_inline_span_tag_end(tag) => { + if let Some((range, content)) = stack.pop() { + let content = content.unwrap_or(range.clone()); + spans.push(InlineSpan { + range, + content, + is_code: false, + }); + } + note_child(&mut stack, event_range); + } + MarkdownEvent::Code | MarkdownEvent::SubstitutedCode(_) => { + let range = inline_code_full_range(source, event_range); + note_child(&mut stack, &range); + spans.push(InlineSpan { + range, + content: event_range.clone(), + is_code: true, + }); + } + _ => note_child(&mut stack, event_range), + } + } + spans +} + +pub(crate) fn rebalanced_markdown_for_selection( + source: &str, + events: &[(Range, MarkdownEvent)], + root_block_starts: &[usize], + selection: Range, +) -> String { + let Some(selection) = snap_to_char_boundaries(source, selection) else { + return String::new(); + }; + + let (start_events, end_events) = boundary_block_events(events, root_block_starts, &selection); + let mut spans = collect_inline_spans(source, start_events); + spans.extend(collect_inline_spans(source, end_events)); + + let Some(selection) = snap_out_of_delimiters(&spans, selection) else { + return String::new(); + }; + + if selection_is_only_inside_code_spans(&spans, &selection) { + return source[selection].to_string(); + } + + rebalance_delimiters(source, &spans, &selection) +} + +/// Returns the events of the root blocks containing each selection boundary. +/// The second slice is empty when both boundaries share a block. +fn boundary_block_events<'a>( + events: &'a [(Range, MarkdownEvent)], + root_block_starts: &[usize], + selection: &Range, +) -> ( + &'a [(Range, MarkdownEvent)], + &'a [(Range, MarkdownEvent)], +) { + if root_block_starts.is_empty() { + return (events, &[]); + } + let start_block = root_block_index(root_block_starts, selection.start); + let end_block = root_block_index(root_block_starts, selection.end); + let start_events = root_block_events(events, root_block_starts, start_block); + if end_block == start_block { + (start_events, &[]) + } else { + ( + start_events, + root_block_events(events, root_block_starts, end_block), + ) + } +} + +fn root_block_index(root_block_starts: &[usize], offset: usize) -> usize { + root_block_starts + .partition_point(|block_start| *block_start <= offset) + .saturating_sub(1) +} + +fn root_block_events<'a>( + events: &'a [(Range, MarkdownEvent)], + root_block_starts: &[usize], + block: usize, +) -> &'a [(Range, MarkdownEvent)] { + let Some(&block_start) = root_block_starts.get(block) else { + return events; + }; + let start = events.partition_point(|(range, _)| range.start < block_start); + let end = match root_block_starts.get(block + 1) { + Some(&next_block_start) => { + events.partition_point(|(range, _)| range.start < next_block_start) + } + None => events.len(), + }; + events.get(start..end).unwrap_or(events) +} + +fn snap_to_char_boundaries(source: &str, selection: Range) -> Option> { + let mut start = selection.start.min(source.len()); + let mut end = selection.end.min(source.len()); + if start >= end { + return None; + } + while start > 0 && !source.is_char_boundary(start) { + start -= 1; + } + while end < source.len() && !source.is_char_boundary(end) { + end += 1; + } + Some(start..end) +} + +/// Shrinks selection boundaries that fall inside delimiter syntax (`**`, +/// etc.) so no delimiter is left half-selected: +/// +/// - an end in `**bold*|*` snaps back to `**bold|**` +/// - a start in `*|*bold**` snaps forward to `**|bold**` +/// +/// This repeats until stable, since snapping can land inside a nested span's +/// delimiter. Returns `None` if the selection becomes empty. +fn snap_out_of_delimiters(spans: &[InlineSpan], selection: Range) -> Option> { + let mut start = selection.start; + let mut end = selection.end; + loop { + let mut changed = false; + for span in spans { + if end > span.range.start && end <= span.content.start { + end = span.range.start; + changed = true; + } else if end > span.content.end && end <= span.range.end { + end = span.content.end; + changed = true; + } + if start >= span.range.start && start < span.content.start { + start = span.content.start; + changed = true; + } else if start >= span.content.end && start < span.range.end { + start = span.range.end; + changed = true; + } + } + if !changed { + break; + } + } + (start < end).then(|| start..end) +} + +fn selection_is_only_inside_code_spans(spans: &[InlineSpan], selection: &Range) -> bool { + let contains = |span: &InlineSpan| { + span.content.start <= selection.start && selection.end <= span.content.end + }; + spans.iter().any(|span| span.is_code && contains(span)) + && !spans.iter().any(|span| !span.is_code && contains(span)) +} + +/// Re-adds delimiters cut off by the selection so the result is well-formed +/// markdown: +/// +/// - selecting `old te` in `**bold text**` yields `**old te**` +/// - nested spans are reopened outermost first: selecting `alic` in +/// `**bold _italic_**` yields `**_alic_**` +fn rebalance_delimiters(source: &str, spans: &[InlineSpan], selection: &Range) -> String { + let nesting_order = |a: &&InlineSpan, b: &&InlineSpan| { + a.range + .start + .cmp(&b.range.start) + .then(b.range.end.cmp(&a.range.end)) + }; + + let mut open_at_start = spans + .iter() + .filter(|span| span.content.start <= selection.start && selection.start < span.content.end) + .collect::>(); + open_at_start.sort_by(nesting_order); + + let mut open_at_end = spans + .iter() + .filter(|span| span.content.start < selection.end && selection.end <= span.content.end) + .collect::>(); + open_at_end.sort_by(nesting_order); + + let mut result = String::new(); + for span in &open_at_start { + result.push_str(span.opening(source)); + } + result.push_str(&source[selection.clone()]); + for span in open_at_end.iter().rev() { + result.push_str(span.closing(source)); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse_markdown_with_options; + use util::test::marked_text_ranges; + + fn markdown_for(source: &str, selection: Range) -> String { + let parsed = parse_markdown_with_options(source, false, false, false); + rebalanced_markdown_for_selection( + source, + &parsed.events, + &parsed.root_block_starts, + selection, + ) + } + + fn markdown_for_marked(marked_source: &str) -> String { + let (source, selection) = marked_range(marked_source); + markdown_for(&source, selection) + } + + #[track_caller] + fn assert_marked_selection(marked_source: &str, expected: &str) { + assert_eq!( + markdown_for_marked(marked_source), + expected, + "source: {marked_source:?}" + ); + } + + #[track_caller] + fn marked_range(marked_source: &str) -> (String, Range) { + let (source, ranges) = marked_text_ranges(marked_source, false); + match ranges.as_slice() { + [selection] => (source, selection.clone()), + _ => panic!("expected exactly one «» range in marked source"), + } + } + + fn inline_spans(source: &str) -> Vec { + let parsed = parse_markdown_with_options(source, false, false, false); + collect_inline_spans(source, &parsed.events) + } + + #[track_caller] + fn assert_snapped(marked_selection: &str, marked_expected: Option<&str>, message: &str) { + let (source, selection) = marked_range(marked_selection); + let expected = marked_expected.map(|marked_expected| { + let (expected_source, range) = marked_range(marked_expected); + assert_eq!( + expected_source, source, + "expected must be marked on the same source" + ); + range + }); + assert_eq!( + snap_out_of_delimiters(&inline_spans(&source), selection), + expected, + "{message}" + ); + } + + #[test] + fn test_snap_out_of_delimiters() { + assert_snapped( + "**«bold»** rest", + Some("**«bold»** rest"), + "boundaries already in content must be untouched", + ); + assert_snapped( + "*«*bold»** rest", + Some("**«bold»** rest"), + "a start in the opening delimiter must advance into the content", + ); + assert_snapped( + "**«bold*»* rest", + Some("**«bold»** rest"), + "an end in the closing delimiter must retreat to the content end", + ); + assert_snapped( + "**bold*«* rest»", + Some("**bold**« rest»"), + "a start in the closing delimiter must advance past the whole span", + ); + assert_snapped( + "«*»*bold** rest", + None, + "an end in the opening delimiter must retreat to before the span", + ); + assert_snapped( + "**bold«**» rest", + None, + "a selection covering only delimiter text must collapse", + ); + } + + #[test] + fn test_snap_out_of_delimiters_cascades_through_nested_spans() { + assert_snapped( + "**`«code`*»*", + Some("**`«code»`**"), + "an end inside bold's closing `**` first snaps to code's closing \ + backtick, so it must cascade into the code content", + ); + } + + #[test] + fn test_snap_to_char_boundaries() { + // `√` occupies bytes 1..4. + let source = "a√b"; + assert_eq!( + snap_to_char_boundaries(source, 0..5), + Some(0..5), + "boundaries already on char boundaries must be untouched" + ); + assert_eq!( + snap_to_char_boundaries(source, 0..2), + Some(0..4), + "an end mid-character must expand forward to keep the character" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..5), + Some(1..5), + "a start mid-character must expand backward to keep the character" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..3), + Some(1..4), + "a selection entirely inside a character must cover the whole character" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..10), + Some(1..5), + "an end past the source must clamp to its length" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..2), + None, + "an empty selection must collapse, even mid-character" + ); + assert_eq!( + snap_to_char_boundaries(source, 5..10), + None, + "a selection entirely past the source must collapse" + ); + assert_eq!( + snap_to_char_boundaries(source, Range { start: 4, end: 2 }), + None, + "a reversed selection must collapse" + ); + } + + fn selection_is_plain(marked_source: &str) -> bool { + let (source, selection) = marked_range(marked_source); + selection_is_only_inside_code_spans(&inline_spans(&source), &selection) + } + + #[test] + fn test_selection_is_only_inside_code_spans() { + assert!( + selection_is_plain("run `«cargo» test` now"), + "a selection fully inside the code span's content must be plain" + ); + assert!( + !selection_is_plain("«run `cargo» test` now"), + "a selection reaching outside the code span must not be plain" + ); + assert!( + !selection_is_plain("**`«code»`**"), + "code nested in bold must not be plain: the bold span also contains it" + ); + } + + #[test] + fn test_markdown_for_selection_balances_inline_spans() { + assert_marked_selection("This is **«bold»** text in a sentence.", "**bold**"); + assert_marked_selection("This is **«bold**» text in a sentence.", "**bold**"); + assert_marked_selection("Th«is is **bo»ld** text in a sentence.", "is is **bo**"); + + assert_marked_selection("This is *«italic»* text in a sentence.", "*italic*"); + assert_marked_selection("This is *it«al»ic* text in a sentence.", "*al*"); + + assert_marked_selection("T«his is `cod»e` all `in one` sentence.", "his is `cod`"); + assert_marked_selection( + "This is `c«ode` all `in o»ne` sentence.", + "`ode` all `in o`", + ); + assert_marked_selection( + "This is `«code` all `in one»` sentence.", + "`code` all `in one`", + ); + assert_marked_selection( + "This is `«code` all `in one`» sentence.", + "`code` all `in one`", + ); + + // Special case for single inline code blocks + assert_marked_selection("This is `«code»` all `in one` sentence.", "code"); + assert_marked_selection("This is `«code`» all `in one` sentence.", "code"); + assert_marked_selection("This is `c«od»e` all `in one` sentence.", "od"); + } + + #[test] + fn test_markdown_for_selection_nested_spans() { + assert_marked_selection("**bo«ld wi»th `code` inside**", "**ld wi**"); + assert_marked_selection("**bold with `c«od»e` inside**", "**`od`**"); + assert_marked_selection("**bold with `«code»` inside**", "**`code`**"); + assert_marked_selection( + "**«bold with `code` inside»**", + "**bold with `code` inside**", + ); + assert_marked_selection( + "«**bold with `code` inside**»", + "**bold with `code` inside**", + ); + } + + #[test] + fn test_markdown_for_selection_links() { + assert_marked_selection( + "[Visit Rust's we«bsite»](https://rust.org)", + "[bsite](https://rust.org)", + ); + assert_marked_selection( + "[«Visit Rust's website»](https://rust.org)", + "[Visit Rust's website](https://rust.org)", + ); + assert_marked_selection("visit https://«example».com now", "example"); + } + + #[test] + fn test_markdown_for_selection_scopes_to_boundary_blocks() { + assert_marked_selection( + "**bo«ld one**\n\nmiddle `x`\n\n*ita»lic two*", + "**ld one**\n\nmiddle `x`\n\n*ita*", + ); + assert_marked_selection("**bold** first\n\nsecond *ita«li»c* here", "*li*"); + assert_marked_selection("one\n«\ntwo»", "\ntwo"); + assert_marked_selection("**one*«*\n\ntw»o", "\n\ntw"); + } + + #[test] + fn test_root_block_index() { + let starts = [2, 10, 20]; + assert_eq!( + root_block_index(&starts, 0), + 0, + "an offset before the first block start must clamp to the first block" + ); + assert_eq!(root_block_index(&starts, 2), 0); + assert_eq!(root_block_index(&starts, 9), 0); + assert_eq!(root_block_index(&starts, 10), 1); + assert_eq!(root_block_index(&starts, 19), 1); + assert_eq!(root_block_index(&starts, 20), 2); + assert_eq!( + root_block_index(&starts, 100), + 2, + "an offset past the last block start must clamp to the last block" + ); + } + + #[test] + fn test_root_block_events_slices_each_block() { + let source = "first **a**\n\nsecond `b`\n\nthird *c*"; + let parsed = parse_markdown_with_options(source, false, false, false); + let events = parsed.events.as_slice(); + let starts = parsed.root_block_starts.as_slice(); + assert_eq!(starts, &[0, 13, 25]); + + let mut sliced_events = Vec::new(); + for block in 0..starts.len() { + let slice = root_block_events(events, starts, block); + assert!(!slice.is_empty(), "block {block} must have events"); + let block_end = starts.get(block + 1).copied().unwrap_or(source.len()); + for (range, event) in slice { + assert!( + (starts[block]..block_end).contains(&range.start), + "event {event:?} at {range:?} must start within block {block}" + ); + } + sliced_events.extend_from_slice(slice); + } + assert_eq!( + sliced_events, events, + "the per-block slices must partition all events in order" + ); + + assert_eq!( + root_block_events(events, starts, starts.len()), + events, + "an out-of-range block index must fall back to all events" + ); + } + + #[test] + fn test_markdown_for_selection_plain_text_and_blocks() { + assert_eq!( + markdown_for_marked("some «text»"), + "text", + "plain text must be unchanged" + ); + assert_eq!( + markdown_for_marked("«para one\n\n- item **bold**\n- item two»"), + "para one\n\n- item **bold**\n- item two", + "selections spanning multiple blocks must keep interior syntax as-is" + ); + assert_eq!( + markdown_for_marked("```rust\n«let x = 1;»\n```"), + "let x = 1;", + "a selection inside a fenced code block must stay plain" + ); + assert_eq!( + markdown_for("abc", 2..100), + "c", + "out-of-bounds ends must be clamped" + ); + assert_eq!( + markdown_for("abc", Range { start: 3, end: 2 }), + "", + "inverted ranges must yield an empty string" + ); + } +} diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index caf516b212f318..0734b4c5016788 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -30,7 +30,9 @@ actions!( /// Scrolls to the bottom of the markdown preview. ScrollToBottom, /// Opens a following markdown preview that syncs with the editor. - OpenFollowingPreview + OpenFollowingPreview, + /// Closes the markdown preview and returns focus to the source editor. + CloseAndReturnToEditor ] ); diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index db6c1a80786db7..b80fe288f7035e 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -25,19 +25,21 @@ use settings::{SeedQuerySetting, Settings, update_settings_file}; use theme::{SystemAppearance, Theme, ThemeRegistry}; use theme_settings::ThemeSettings; use ui::utils::WithRemSize; -use ui::{ContextMenu, WithScrollbar, prelude::*, right_click_menu}; +use ui::{ContextMenu, LinkPreview, WithScrollbar, prelude::*, right_click_menu}; +use util::ResultExt; use util::markdown::split_local_url_fragment; use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem}; use workspace::notifications::NotifyResultExt; use workspace::searchable::{ Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, }; -use workspace::{ItemId, Pane, Workspace, WorkspaceId, delete_unloaded_items}; +use workspace::{ItemId, Pane, SaveIntent, Workspace, WorkspaceId, delete_unloaded_items}; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; use crate::markdown_preview_settings::MarkdownPreviewSettings; use crate::{ - OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem, + CloseAndReturnToEditor, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, + ScrollDownByItem, }; use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; @@ -54,6 +56,7 @@ pub struct MarkdownPreviewView { image_cache: Entity, base_directory: Option, pending_update_task: Option>>, + hovered_url: Option, mode: MarkdownPreviewMode, } @@ -96,44 +99,15 @@ impl MarkdownPreviewView { pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { - let view = Self::create_markdown_view(workspace, editor.clone(), window, cx); - workspace.active_pane().update(cx, |pane, cx| { - if let Some(existing_view_idx) = - Self::find_existing_independent_preview_item_idx(pane, &editor, cx) - { - pane.activate_item(existing_view_idx, true, true, window, cx); - } else { - pane.add_item(Box::new(view.clone()), true, true, None, window, cx) - } - }); - cx.notify(); + let pane = workspace.active_pane().clone(); + Self::open_preview_in_pane(workspace, editor, pane, window, cx); } }); workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { - let view = Self::create_markdown_view(workspace, editor.clone(), window, cx); - let pane = workspace - .find_pane_in_direction(workspace::SplitDirection::Right, cx) - .unwrap_or_else(|| { - workspace.split_pane( - workspace.active_pane().clone(), - workspace::SplitDirection::Right, - window, - cx, - ) - }); - pane.update(cx, |pane, cx| { - if let Some(existing_view_idx) = - Self::find_existing_independent_preview_item_idx(pane, &editor, cx) - { - pane.activate_item(existing_view_idx, true, true, window, cx); - } else { - pane.add_item(Box::new(view.clone()), false, false, None, window, cx) - } - }); - editor.focus_handle(cx).focus(window, cx); - cx.notify(); + let pane = workspace.active_pane().clone(); + Self::open_preview_to_the_side_of_pane(workspace, editor, pane, window, cx); } }); @@ -163,6 +137,51 @@ impl MarkdownPreviewView { }); } + pub fn open_preview_in_pane( + workspace: &mut Workspace, + editor: Entity, + pane: Entity, + window: &mut Window, + cx: &mut Context, + ) { + Self::activate_or_add_preview(workspace, editor, pane, true, window, cx); + } + + pub fn open_preview_to_the_side_of_pane( + workspace: &mut Workspace, + editor: Entity, + origin_pane: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx); + Self::activate_or_add_preview(workspace, editor.clone(), target_pane, false, window, cx); + editor.focus_handle(cx).focus(window, cx); + } + + fn activate_or_add_preview( + workspace: &mut Workspace, + editor: Entity, + pane: Entity, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let existing_view_idx = + Self::find_existing_independent_preview_item_idx(pane.read(cx), &editor, cx); + if let Some(existing_view_idx) = existing_view_idx { + pane.update(cx, |pane, cx| { + pane.activate_item(existing_view_idx, focus, focus, window, cx); + }); + } else { + let view = Self::create_markdown_view(workspace, editor, window, cx); + pane.update(cx, |pane, cx| { + pane.add_item(Box::new(view), focus, focus, None, window, cx) + }); + } + cx.notify(); + } + fn find_existing_independent_preview_item_idx( pane: &Pane, editor: &Entity, @@ -285,6 +304,7 @@ impl MarkdownPreviewView { image_cache: RetainAllImageCache::new(cx), base_directory: None, pending_update_task: None, + hovered_url: None, mode, }; @@ -375,7 +395,7 @@ impl MarkdownPreviewView { .detach(); } - pub fn is_markdown_file(editor: &Entity, cx: &mut Context) -> bool { + pub fn is_markdown_file(editor: &Entity, cx: &App) -> bool { let buffer = editor.read(cx).buffer().read(cx); if let Some(buffer) = buffer.as_singleton() && let Some(language) = buffer.read(cx).language() @@ -432,6 +452,7 @@ impl MarkdownPreviewView { ); self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx); + self.hovered_url = None; self.active_editor = Some(EditorState { editor, _subscription: subscription, @@ -543,6 +564,7 @@ impl MarkdownPreviewView { view.update(cx, move |view, cx| { if let Some((contents, selection_start)) = update { + view.hovered_url = None; view.markdown.update(cx, |markdown, cx| { markdown.reset(contents, cx); }); @@ -596,9 +618,10 @@ impl MarkdownPreviewView { }); } - fn move_cursor_to_source_index( + fn change_selection_to_source_index( editor: &Entity, source_index: usize, + move_focus: bool, window: &mut Window, cx: &mut App, ) { @@ -610,7 +633,9 @@ impl MarkdownPreviewView { cx, |selections| selections.select_ranges(vec![selection]), ); - window.focus(&editor.focus_handle(cx), cx); + if move_focus { + window.focus(&editor.focus_handle(cx), cx); + } }); } @@ -803,6 +828,40 @@ impl MarkdownPreviewView { cx.notify(); } + fn close_and_return_to_editor( + &mut self, + _: &CloseAndReturnToEditor, + window: &mut Window, + cx: &mut Context, + ) { + let Some(editor) = self + .active_editor + .as_ref() + .map(|state| state.editor.clone()) + else { + return; + }; + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let preview_id = cx.entity_id(); + + window.defer(cx, move |window, cx| { + workspace.update(cx, |workspace, cx| { + if !workspace.activate_item(&editor, true, true, window, cx) { + workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); + } + + if let Some(pane) = workspace.pane_for_item_id(preview_id) { + pane.update(cx, |pane, cx| { + pane.close_item_by_id(preview_id, SaveIntent::Skip, window, cx) + }) + .detach_and_log_err(cx); + } + }); + }); + } + /// Returns the theme chosen in `markdown_preview_theme`, or `None` if the /// user hasn't set one or it can't be resolved. fn resolve_preview_theme(&self, cx: &App) -> Option> { @@ -875,6 +934,19 @@ impl MarkdownPreviewView { cx, ); } + }) + .on_url_hover({ + let view_handle = cx.entity().downgrade(); + move |hovered_url, _window, cx| { + view_handle + .update(cx, |view, cx| { + if view.hovered_url != hovered_url { + view.hovered_url = hovered_url; + cx.notify(); + } + }) + .log_err(); + } }); if let Some(active_editor) = active_editor { @@ -882,12 +954,16 @@ impl MarkdownPreviewView { 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 + if click_count == 1 { + Self::change_selection_to_source_index( + &active_editor, + source_index, + false, + window, + cx, + ); } + false }) .on_checkbox_toggle(move |source_range, new_checked, window, cx| { Self::apply_checkbox_toggle_to_editor( @@ -976,9 +1052,10 @@ fn handle_url_click( if let Some(source_index) = source_index { if let Some(editor) = active_editor { - MarkdownPreviewView::move_cursor_to_source_index( + MarkdownPreviewView::change_selection_to_source_index( &editor, source_index, + true, window, cx, ); @@ -1225,11 +1302,17 @@ impl Render for MarkdownPreviewView { .map(|theme| theme.colors().editor_background) .unwrap_or_else(|| cx.theme().colors().editor_background); let preview_font_size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx); + let hovered_url = self.hovered_url.clone(); div() .image_cache(self.image_cache.clone()) .id("MarkdownPreview") .key_context("MarkdownPreview") .track_focus(&self.focus_handle(cx)) + .on_hover(cx.listener(|view, hovered, _window, cx| { + if !hovered && view.hovered_url.take().is_some() { + cx.notify(); + } + })) .on_action(cx.listener(MarkdownPreviewView::scroll_page_up)) .on_action(cx.listener(MarkdownPreviewView::scroll_page_down)) .on_action(cx.listener(MarkdownPreviewView::scroll_up)) @@ -1238,12 +1321,14 @@ impl Render for MarkdownPreviewView { .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item)) .on_action(cx.listener(MarkdownPreviewView::scroll_to_top)) .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom)) + .on_action(cx.listener(MarkdownPreviewView::close_and_return_to_editor)) .on_action(cx.listener(MarkdownPreviewView::increase_font_size)) .on_action(cx.listener(MarkdownPreviewView::decrease_font_size)) .on_action(cx.listener(MarkdownPreviewView::reset_font_size)) .w_full() .flex_1() .min_h_0() + .relative() .bg(bg_color) .child( WithRemSize::new(preview_font_size).size_full().child( @@ -1321,6 +1406,17 @@ impl Render for MarkdownPreviewView { ), ) .vertical_scrollbar_for(&self.scroll_handle, window, cx) + .when_some(hovered_url, |this, hovered_url| { + this.child( + div() + .absolute() + .bottom_2() + .left_0() + .max_w_full() + .overflow_hidden() + .child(LinkPreview::new(hovered_url.as_ref(), cx)), + ) + }) } } @@ -1385,7 +1481,11 @@ impl SearchableItem for MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) -> String { - self.markdown.read(cx).selected_text().unwrap_or_default() + self.markdown + .read(cx) + .selected_source() + .unwrap_or_default() + .to_string() } fn activate_match( @@ -1614,12 +1714,13 @@ mod persistence { #[cfg(test)] mod tests { + use crate::CloseAndReturnToEditor; use crate::markdown_preview_view::ImageSource; use crate::markdown_preview_view::Resource; use crate::markdown_preview_view::resolve_preview_image; use buffer_diff::BufferDiff; use editor::Editor; - use gpui::{AppContext as _, Entity, TestAppContext}; + use gpui::{AppContext as _, Entity, Focusable as _, TestAppContext, WindowHandle}; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; @@ -1901,6 +2002,69 @@ mod tests { ); } + #[gpui::test] + async fn close_and_return_to_editor_closes_preview_and_focuses_source_editor( + cx: &mut TestAppContext, + ) { + let (multi_workspace, editor) = + open_markdown_file(cx, "note.md", "# Note\n\nBody text\n").await; + let preview = open_preview_for_active_editor(cx, &multi_workspace); + cx.run_until_parked(); + + dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview); + cx.run_until_parked(); + + assert_editor_is_active_and_focused(cx, &multi_workspace, &editor); + assert_no_markdown_preview_items(cx, &multi_workspace); + } + + #[gpui::test] + async fn close_and_return_to_editor_reopens_source_editor_when_editor_tab_was_closed( + cx: &mut TestAppContext, + ) { + let (multi_workspace, editor) = open_markdown_file(cx, "note.md", "# Note\n").await; + let preview = open_preview_for_active_editor(cx, &multi_workspace); + cx.run_until_parked(); + + let close_editor_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_item_by_id(editor.entity_id(), SaveIntent::Skip, window, cx) + }) + }) + }) + .unwrap(); + close_editor_task.await.unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().read(cx); + assert!( + preview.read(cx).focus_handle.contains_focused(window, cx), + "preview should remain focused after closing the source editor tab" + ); + assert!( + workspace + .items_of_type::(cx) + .all(|open_editor| open_editor != editor), + "source editor should no longer be open in any pane" + ); + assert_eq!( + workspace.items_of_type::(cx).count(), + 1 + ); + }) + .unwrap(); + + dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview); + cx.run_until_parked(); + + assert_editor_is_active_and_focused(cx, &multi_workspace, &editor); + assert_no_markdown_preview_items(cx, &multi_workspace); + } + #[gpui::test] async fn preview_serialized_path_updates_when_source_file_is_renamed(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -2248,6 +2412,238 @@ mod tests { ); } + async fn open_markdown_file( + cx: &mut TestAppContext, + file_name: &str, + contents: &str, + ) -> (WindowHandle, Entity) { + let app_state = init_test(cx); + let mut entries = serde_json::Map::new(); + entries.insert(file_name.to_string(), json!(contents)); + app_state + .fs + .as_fake() + .insert_tree(path!("/dir"), serde_json::Value::Object(entries)) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir")).join(file_name)], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .read(cx) + .active_item_as::(cx) + .unwrap() + }) + .unwrap(); + (multi_workspace, editor) + } + + fn open_preview_for_active_editor( + cx: &mut TestAppContext, + multi_workspace: &WindowHandle, + ) -> Entity { + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let editor: Entity = workspace.active_item_as(cx).unwrap(); + let preview = + MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) + }); + preview + }) + }) + .unwrap() + } + + fn dispatch_close_and_return_to_editor( + cx: &mut TestAppContext, + multi_workspace: &WindowHandle, + preview: &Entity, + ) { + multi_workspace + .update(cx, |_, window, cx| { + assert!( + preview.read(cx).focus_handle.contains_focused(window, cx), + "preview must be focused for the keyboard action to dispatch to it" + ); + window.dispatch_action(Box::new(CloseAndReturnToEditor), cx); + }) + .unwrap(); + } + + fn assert_editor_is_active_and_focused( + cx: &mut TestAppContext, + multi_workspace: &WindowHandle, + expected_editor: &Entity, + ) { + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().read(cx); + let active_editor = workspace.active_item_as::(cx).unwrap(); + assert_eq!(active_editor, *expected_editor); + assert!( + expected_editor + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx), + "source editor should be focused" + ); + }) + .unwrap(); + } + + fn assert_no_markdown_preview_items( + cx: &mut TestAppContext, + multi_workspace: &WindowHandle, + ) { + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert_eq!( + multi_workspace + .workspace() + .read(cx) + .items_of_type::(cx) + .count(), + 0 + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn preview_opens_for_the_given_pane_not_the_focused_editor(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.md": "# A\n", + "b.md": "# B\n" + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir/a.md"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let workspace = multi_workspace + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + let b_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/b.md"), cx) + }) + .await + .unwrap(); + + let (first_pane, a_editor, second_pane, b_editor) = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let first_pane = workspace.active_pane().clone(); + let a_editor: Entity = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .unwrap(); + let project = workspace.project().clone(); + let second_pane = workspace.split_pane( + first_pane.clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + let b_editor = + cx.new(|cx| Editor::for_buffer(b_buffer, Some(project), window, cx)); + second_pane.update(cx, |pane, cx| { + pane.add_item(Box::new(b_editor.clone()), true, true, None, window, cx) + }); + (first_pane, a_editor, second_pane, b_editor) + }) + }) + .unwrap(); + cx.run_until_parked(); + + // With focus in the second pane (`b.md`), simulate clicking the + // preview button in the first pane's toolbar, which targets that + // pane's own editor (`a.md`). + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.active_pane(), + &second_pane, + "test precondition: focus must be in the second pane" + ); + MarkdownPreviewView::open_preview_in_pane( + workspace, + a_editor.clone(), + first_pane.clone(), + window, + cx, + ); + }) + }) + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + let preview = first_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("the preview must open in the pane whose button was clicked"); + let bound_editor = preview + .read(cx) + .active_editor + .as_ref() + .unwrap() + .editor + .clone(); + assert_eq!( + bound_editor, a_editor, + "the preview must be bound to the clicked pane's editor, not the focused editor" + ); + assert_eq!( + second_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()), + Some(b_editor), + "the focused pane's content must be unaffected" + ); + }); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); diff --git a/crates/migrator/Cargo.toml b/crates/migrator/Cargo.toml index cb97f7fb6ced68..162473060ab8e6 100644 --- a/crates/migrator/Cargo.toml +++ b/crates/migrator/Cargo.toml @@ -19,7 +19,7 @@ convert_case.workspace = true log.workspace = true streaming-iterator.workspace = true tree-sitter-json.workspace = true -tree-sitter.workspace = true +tree-sitter = { workspace = true, features = ["wasm"] } serde_json_lenient.workspace = true serde_json.workspace = true settings_content.workspace = true diff --git a/crates/migrator/src/migrations/m_2025_10_02/settings.rs b/crates/migrator/src/migrations/m_2025_10_02/settings.rs index 8942008e63219b..bb2a5bbb299fa8 100644 --- a/crates/migrator/src/migrations/m_2025_10_02/settings.rs +++ b/crates/migrator/src/migrations/m_2025_10_02/settings.rs @@ -14,9 +14,9 @@ fn remove_formatters_on_save_inner(value: &mut Value, path: &[&str]) -> Result<( let Some(format_on_save) = obj.get("format_on_save").cloned() else { return Ok(()); }; - let is_format_on_save_set_to_formatter = format_on_save - .as_str() - .map_or(true, |s| s != "on" && s != "off"); + let is_format_on_save_set_to_formatter = format_on_save.as_str().map_or(true, |s| { + s != "on" && s != "off" && s != "modifications" && s != "modifications_if_available" + }); if !is_format_on_save_set_to_formatter { return Ok(()); } diff --git a/crates/multi_buffer/Cargo.toml b/crates/multi_buffer/Cargo.toml index 5dccddaba62735..fa680c4fc57d8b 100644 --- a/crates/multi_buffer/Cargo.toml +++ b/crates/multi_buffer/Cargo.toml @@ -41,7 +41,7 @@ smallvec.workspace = true sum_tree.workspace = true text.workspace = true theme.workspace = true -tree-sitter.workspace = true +tree-sitter = { workspace = true, features = ["wasm"] } ztracing.workspace = true tracing.workspace = true util.workspace = true diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 5b61e6237e7051..9ac5396db77025 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -626,7 +626,7 @@ impl DiffState { this.buffer_diff_changed(diff, range, cx); cx.emit(Event::BufferDiffChanged); } - BufferDiffEvent::BaseTextChanged | BufferDiffEvent::HunksStagedOrUnstaged(_) => {} + BufferDiffEvent::BaseTextChanged => {} }), diff, main_buffer: None, @@ -660,8 +660,7 @@ impl DiffState { ); cx.emit(Event::BufferDiffChanged); } - BufferDiffEvent::BaseTextChanged - | BufferDiffEvent::HunksStagedOrUnstaged(_) => {} + BufferDiffEvent::BaseTextChanged => {} } } }), @@ -2115,6 +2114,9 @@ impl MultiBuffer { self.title.as_deref() } + /// The title used for buffers not backed by a file and with no title of their own. + pub const DEFAULT_TITLE: &str = "untitled"; + pub fn title<'a>(&'a self, cx: &'a App) -> Cow<'a, str> { if let Some(title) = self.title.as_ref() { return title.into(); @@ -2132,7 +2134,7 @@ impl MultiBuffer { } }; - "untitled".into() + Self::DEFAULT_TITLE.into() } fn buffer_content_title(&self, buffer: &Buffer) -> Option> { @@ -2551,7 +2553,7 @@ impl MultiBuffer { *non_text_state_update_count += 1; } - paths_to_edit.sort_unstable_by_key(|(path, _, _, _)| path.clone()); + paths_to_edit.sort_unstable_by(|a, b| a.0.cmp(&b.0)); let mut edits = Vec::new(); let mut new_excerpts = SumTree::default(); diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 18423a69608c0a..436cd5763d22d0 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -49,7 +49,7 @@ impl PathKey { } else { Self { sort_prefix: None, - path: RelPath::unix(&buffer.entity_id().to_string()) + path: RelPath::from_unix_str(&buffer.entity_id().to_string()) .unwrap() .into_arc(), } diff --git a/crates/node_runtime/Cargo.toml b/crates/node_runtime/Cargo.toml index 25f7b2997e5e72..5da2f60f124e4b 100644 --- a/crates/node_runtime/Cargo.toml +++ b/crates/node_runtime/Cargo.toml @@ -34,4 +34,4 @@ watch.workspace = true which.workspace = true [target.'cfg(windows)'.dependencies] -async-std = { version = "1.12.0", features = ["unstable"] } +async-std.workspace = true diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index e960634a842e79..6a2d1d7c3d3adb 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared}; use http_client::{Host, HttpClient, Url}; use log::Level; -use semver::Version; +use semver::{Version, VersionReq}; use serde::Deserialize; use smol::io::BufReader; use smol::{fs, lock::Mutex}; @@ -253,6 +253,15 @@ impl NodeRuntime { } pub async fn npm_package_latest_version(&self, name: &str) -> Result { + self.npm_package_latest_version_with_requirement(name, None) + .await + } + + pub async fn npm_package_latest_version_with_requirement( + &self, + name: &str, + version_requirement: Option<&VersionReq>, + ) -> Result { let http = self.0.lock().await.http.clone(); let instance = self.instance().await; let output = instance @@ -273,16 +282,22 @@ impl NodeRuntime { ) .await?; - let info: NpmInfo = serde_json::from_slice(&output.stdout)?; + let info: NpmInfo = deserialize_npm_info_from_response(&output.stdout).map_err(|e| { + anyhow::anyhow!( + "failed to parse npm info response: {e}\nstdout: {}", + String::from_utf8_lossy(&output.stdout) + ) + })?; let before = npm_config_before(instance.as_ref(), http.proxy()) .await .context("getting npm before config") .log_err() .flatten(); let latest_dist_tag = info.dist_tags.latest.clone(); - let selected_version = select_npm_package_version(name, info, before.as_deref())?; + let selected_version = + select_npm_package_version(name, info, before.as_deref(), version_requirement)?; log::debug!( - "selected latest npm package version package={name:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}" + "selected latest npm package version package={name:?} version_requirement={version_requirement:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}" ); Ok(selected_version) } @@ -412,6 +427,21 @@ pub struct NpmInfo { time: HashMap, } +/// Parse NpmInfo from npm info --json output, handling both v11 and >= v12 formats. +fn deserialize_npm_info_from_response(data: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(data)?; + + // npm >= 12 returns an array with one object: [ { ... } ] + if let serde_json::Value::Array(arr) = &value { + if arr.len() == 1 { + return NpmInfo::deserialize(&arr[0]); + } + } + + // npm <= v11 returns a bare JSON object: { ... } + NpmInfo::deserialize(value) +} + #[derive(Debug, Deserialize, Default)] pub struct NpmInfoDistTags { latest: Option, @@ -461,7 +491,19 @@ fn select_npm_package_version( package_name: &str, mut info: NpmInfo, before: Option<&str>, + version_requirement: Option<&VersionReq>, ) -> Result { + if let Some(version_requirement) = version_requirement { + info.versions + .retain(|version| version_requirement.matches(version)); + info.versions.sort(); + info.dist_tags.latest = info + .dist_tags + .latest + .take() + .filter(|version| version_requirement.matches(version)); + } + if let Some(before) = before && !info.time.is_empty() { @@ -482,6 +524,7 @@ fn select_npm_package_version( latest_version, &info.time, &before_timestamp, + version_requirement.is_some(), )? { return Ok(version.clone()); } @@ -501,8 +544,9 @@ fn is_allowed_npm_version_before( latest_version: Option<&Version>, published_at_by_version: &HashMap, before: &DateTime, + allow_prereleases: bool, ) -> Result { - if !version.pre.is_empty() + if (!allow_prereleases && !version.pre.is_empty()) || latest_version.is_some_and(|latest_version| version > latest_version) { return Ok(false); @@ -1101,11 +1145,11 @@ mod tests { use anyhow::{Result, bail}; use http_client::Url; - use semver::Version; + use semver::{Version, VersionReq}; use super::{ - NpmInfo, VersionStrategy, build_npm_command_args, proxy_argument, - select_npm_package_version, should_install_npm_package_version, + NpmInfo, VersionStrategy, build_npm_command_args, deserialize_npm_info_from_response, + proxy_argument, select_npm_package_version, should_install_npm_package_version, }; // Map localhost to 127.0.0.1 @@ -1225,7 +1269,7 @@ mod tests { )?; assert_eq!( - select_npm_package_version("test-package", info, None)?, + select_npm_package_version("test-package", info, None, None)?, Version::parse("3.0.0")? ); Ok(()) @@ -1250,7 +1294,12 @@ mod tests { )?; assert_eq!( - select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + None + )?, Version::parse("2.0.0")? ); Ok(()) @@ -1271,7 +1320,12 @@ mod tests { )?; assert_eq!( - select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + None + )?, Version::parse("2.0.0")? ); Ok(()) @@ -1292,7 +1346,12 @@ mod tests { )?; assert_eq!( - select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + None + )?, Version::parse("2.0.0")? ); Ok(()) @@ -1312,7 +1371,12 @@ mod tests { )?; assert_eq!( - select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + None + )?, Version::parse("2.0.0-beta.1")? ); Ok(()) @@ -1333,7 +1397,12 @@ mod tests { )?; assert_eq!( - select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + None + )?, Version::parse("1.0.0")? ); Ok(()) @@ -1354,7 +1423,12 @@ mod tests { )?; assert_eq!( - select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + None + )?, Version::parse("1.0.0")? ); Ok(()) @@ -1373,9 +1447,12 @@ mod tests { }"#, )?; - let Err(error) = - select_npm_package_version("test-package", info, Some("2023-12-01T00:00:00.000Z")) - else { + let Err(error) = select_npm_package_version( + "test-package", + info, + Some("2023-12-01T00:00:00.000Z"), + None, + ) else { bail!("expected cutoff to reject all package versions"); }; assert_eq!( @@ -1384,4 +1461,152 @@ mod tests { ); Ok(()) } + + #[test] + fn test_select_npm_package_version_selects_latest_matching_requirement() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "7.0.0" }, + "versions": ["6.0.3", "7.0.0", "5.9.3", "6.0.2"] + }"#, + )?; + let version_requirement = VersionReq::parse("^6")?; + + assert_eq!( + select_npm_package_version("test-package", info, None, Some(&version_requirement))?, + Version::parse("6.0.3")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_applies_before_to_matching_versions() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "7.0.0" }, + "versions": ["6.0.3", "7.0.0", "6.0.2"], + "time": { + "6.0.2": "2024-02-01T00:00:00.000Z", + "6.0.3": "2024-03-01T00:00:00.000Z", + "7.0.0": "2024-04-01T00:00:00.000Z" + } + }"#, + )?; + let version_requirement = VersionReq::parse("^6")?; + + assert_eq!( + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + Some(&version_requirement), + )?, + Version::parse("6.0.2")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_allows_requested_prerelease_before_cutoff() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "7.0.0" }, + "versions": ["7.1.0-beta.1", "7.1.0-beta.2", "7.0.0"], + "time": { + "7.0.0": "2024-01-01T00:00:00.000Z", + "7.1.0-beta.1": "2024-02-01T00:00:00.000Z", + "7.1.0-beta.2": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + let version_requirement = VersionReq::parse(">=7.1.0-beta.1, <7.1.0")?; + + assert_eq!( + select_npm_package_version( + "test-package", + info, + Some("2024-02-15T00:00:00.000Z"), + Some(&version_requirement), + )?, + Version::parse("7.1.0-beta.1")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_errors_without_matching_version() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "7.0.0" }, + "versions": ["5.9.3", "7.0.0"] + }"#, + )?; + let version_requirement = VersionReq::parse("^6")?; + + let error = + select_npm_package_version("test-package", info, None, Some(&version_requirement)) + .expect_err("expected version requirement to reject all package versions"); + assert_eq!( + error.to_string(), + "no version found for npm package test-package" + ); + Ok(()) + } + + #[test] + fn test_pinned_version_strategy_replaces_different_installed_version() -> Result<()> { + let pinned_version = Version::parse("6.0.3")?; + + assert!(!should_install_npm_package_version( + &pinned_version, + VersionStrategy::Pin(&pinned_version) + )); + assert!(should_install_npm_package_version( + &Version::parse("7.0.0")?, + VersionStrategy::Pin(&pinned_version) + )); + Ok(()) + } + + #[test] + fn test_deserialize_npm_info_npm11_format() -> Result<()> { + let json = r#"{ + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"] + }"#; + + let info = deserialize_npm_info_from_response(json.as_bytes())?; + assert_eq!(info.dist_tags.latest, Some(Version::parse("3.0.0")?)); + assert_eq!( + info.versions, + vec![ + Version::parse("1.0.0")?, + Version::parse("2.0.0")?, + Version::parse("3.0.0")? + ] + ); + Ok(()) + } + + #[test] + fn test_deserialize_npm_v12_format() -> Result<()> { + let json = r#"[ + { + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"] + } + ]"#; + + let info = deserialize_npm_info_from_response(json.as_bytes())?; + assert_eq!(info.dist_tags.latest, Some(Version::parse("3.0.0")?)); + assert_eq!( + info.versions, + vec![ + Version::parse("1.0.0")?, + Version::parse("2.0.0")?, + Version::parse("3.0.0")? + ] + ); + Ok(()) + } } diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index a4b17a0c09bc40..40fe15c08945ac 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -565,20 +565,28 @@ fn render_registry_agent_button( .name(agent.name().clone()) .state(state_element) .disabled(installed) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { telemetry::event!("Welcome Agent Install Clicked", agent = agent_id.as_str()); - let agent_id = agent_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let agent_servers = settings.agent_servers.get_or_insert_default(); - agent_servers.entry(agent_id).or_insert_with(|| { - CustomAgentServerSettings::Registry { - env: Default::default(), - default_mode: None, - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - } - }); + update_settings_file(fs.clone(), cx, { + let agent_id = agent_id.clone(); + move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + agent_servers.entry(agent_id).or_insert_with(|| { + CustomAgentServerSettings::Registry { + env: Default::default(), + default_mode: None, + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + }); + } }); + window.dispatch_action( + Box::new(zed_actions::agent::SelectAgent { + agent: agent_id.clone(), + }), + cx, + ); }) } diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index f39a9af218fd42..18798a54b17ad7 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -3,16 +3,19 @@ use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelImage, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, - Role, StopReason, TokenUsage, - util::{fix_streamed_json, parse_tool_arguments}, + LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage, + LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestToolInput, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, + LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason, + TokenUsage, + util::{fix_streamed_json, is_context_window_exceeded_message, parse_tool_arguments}, }; use std::pin::Pin; use std::sync::Arc; use crate::responses::{ - ContextManagement, Request as ResponseRequest, ResponseCompactionItem, ResponseError, + ContextManagement, Request as ResponseRequest, ResponseCompactionItem, + ResponseCustomToolCallItem, ResponseCustomToolCallOutputItem, ResponseError, ResponseFunctionCallItem, ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem, ResponseIncludable, ResponseInputContent, ResponseInputItem, ResponseMessageItem, ResponseOutputItem, ResponseOutputMessage, ResponseReasoningInputItem, ResponseReasoningItem, @@ -52,7 +55,17 @@ pub fn into_open_ai( max_tokens_parameter: ChatCompletionMaxTokensParameter, reasoning_effort: Option, interleaved_reasoning: bool, -) -> crate::Request { +) -> Result { + if request + .tools + .iter() + .any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. })) + { + return Err(anyhow!( + "OpenAI Chat Completions does not support custom tools; use Responses API instead" + )); + } + let stream = !model_id.starts_with("o1-"); let service_tier = service_tier_for(request.speed); @@ -103,13 +116,18 @@ pub fn into_open_ai( ); } MessageContent::ToolUse(tool_use) => { + let LanguageModelToolUseInput::Json(input) = &tool_use.input else { + return Err(anyhow!( + "OpenAI Chat Completions cannot replay custom tool call `{}`", + tool_use.name + )); + }; let tool_call = ToolCall { id: tool_use.id.to_string(), content: ToolCallContent::Function { function: FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -152,7 +170,7 @@ pub fn into_open_ai( } } - crate::Request { + Ok(crate::Request { model: model_id.into(), messages, stream, @@ -184,14 +202,21 @@ pub fn into_open_ai( tools: request .tools .into_iter() - .map(|tool| crate::ToolDefinition::Function { - function: FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(crate::ToolDefinition::Function { + function: FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) + } + LanguageModelRequestToolInput::Custom { .. } => Err(anyhow!( + "OpenAI Chat Completions does not support custom tools; use Responses API instead" + )), }) - .collect(), + .collect::>()?, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => crate::ToolChoice::Auto, LanguageModelToolChoice::Any => crate::ToolChoice::Required, @@ -199,7 +224,7 @@ pub fn into_open_ai( }), reasoning_effort, service_tier, - } + }) } pub fn into_open_ai_response( @@ -232,22 +257,39 @@ pub fn into_open_ai_response( let mut input_items = Vec::new(); let mut replayed_reasoning_item_indexes = HashMap::default(); + let mut tool_use_kinds_by_id = HashMap::default(); for (index, message) in messages.into_iter().enumerate() { append_message_to_response_items( message, index, &mut replayed_reasoning_item_indexes, + &mut tool_use_kinds_by_id, &mut input_items, ); } let tools: Vec<_> = tools .into_iter() - .map(|tool| crate::responses::ToolDefinition::Function { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - strict: None, + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { input_schema, .. } => { + crate::responses::ToolDefinition::Function { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + strict: None, + } + } + LanguageModelRequestToolInput::Custom { format } => { + crate::responses::ToolDefinition::Custom { + name: tool.name, + description: if tool.description.is_empty() { + None + } else { + Some(tool.description) + }, + format: format.map(custom_tool_format_into_open_ai), + } + } }) .collect(); @@ -323,6 +365,7 @@ fn append_message_to_response_items( message: LanguageModelRequestMessage, index: usize, replayed_reasoning_item_indexes: &mut HashMap, + tool_use_kinds_by_id: &mut HashMap, input_items: &mut Vec, ) { let mut content_parts: Vec = Vec::new(); @@ -386,11 +429,29 @@ fn append_message_to_response_items( input_items, ); let call_id = tool_use.id.to_string(); - input_items.push(ResponseInputItem::FunctionCall(ResponseFunctionCallItem { - call_id, - name: tool_use.name.to_string(), - arguments: tool_use.raw_input, - })); + match tool_use.input { + LanguageModelToolUseInput::Json(_) => { + tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Function); + input_items.push(ResponseInputItem::FunctionCall( + ResponseFunctionCallItem { + call_id, + name: tool_use.name.to_string(), + arguments: tool_use.raw_input, + }, + )); + } + LanguageModelToolUseInput::Text(_) => { + tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Custom); + input_items.push(ResponseInputItem::CustomToolCall( + ResponseCustomToolCallItem { + id: None, + call_id, + name: tool_use.name.to_string(), + input: tool_use.raw_input, + }, + )); + } + } } MessageContent::ToolResult(tool_result) => { flush_response_parts( @@ -424,12 +485,24 @@ fn append_message_to_response_items( ResponseFunctionCallOutputContent::List(parts) } }; - input_items.push(ResponseInputItem::FunctionCallOutput( - ResponseFunctionCallOutputItem { - call_id: tool_result.tool_use_id.to_string(), - output, - }, - )); + match tool_use_kinds_by_id.get(&tool_result.tool_use_id) { + Some(ReplayToolKind::Custom) => { + input_items.push(ResponseInputItem::CustomToolCallOutput( + ResponseCustomToolCallOutputItem { + call_id: tool_result.tool_use_id.to_string(), + output, + }, + )); + } + Some(ReplayToolKind::Function) | None => { + input_items.push(ResponseInputItem::FunctionCallOutput( + ResponseFunctionCallOutputItem { + call_id: tool_result.tool_use_id.to_string(), + output, + }, + )); + } + } } } } @@ -443,6 +516,33 @@ fn append_message_to_response_items( ); } +#[derive(Clone, Copy)] +enum ReplayToolKind { + Function, + Custom, +} + +fn custom_tool_format_into_open_ai( + format: LanguageModelCustomToolFormat, +) -> crate::responses::CustomToolFormat { + match format { + LanguageModelCustomToolFormat::Text => crate::responses::CustomToolFormat::Text, + LanguageModelCustomToolFormat::Grammar { syntax, definition } => { + crate::responses::CustomToolFormat::Grammar { + syntax: match syntax { + LanguageModelCustomToolGrammarSyntax::Lark => { + crate::responses::CustomToolGrammarSyntax::Lark + } + LanguageModelCustomToolGrammarSyntax::Regex => { + crate::responses::CustomToolGrammarSyntax::Regex + } + }, + definition, + } + } + } +} + fn append_reasoning_details_to_response_items( reasoning_details: Option<&serde_json::Value>, replayed_reasoning_item_indexes: &mut HashMap, @@ -665,7 +765,7 @@ impl OpenAiEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -688,7 +788,7 @@ impl OpenAiEventMapper { id: tool_call.id.clone().into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments.clone(), thought_signature: None, }, @@ -736,6 +836,7 @@ struct RawToolCall { pub struct OpenAiResponseEventMapper { function_calls_by_item: HashMap, + custom_tool_calls_by_item: HashMap, reasoning_items: Vec, current_message_phase: Option, pending_stop_reason: Option, @@ -748,10 +849,17 @@ struct PendingResponseFunctionCall { arguments: String, } +struct PendingResponseCustomToolCall { + call_id: String, + name: Arc, + input: String, +} + impl OpenAiResponseEventMapper { pub fn new() -> Self { Self { function_calls_by_item: HashMap::default(), + custom_tool_calls_by_item: HashMap::default(), reasoning_items: Vec::new(), current_message_phase: None, pending_stop_reason: None, @@ -805,6 +913,23 @@ impl OpenAiResponseEventMapper { self.function_calls_by_item.insert(item_id, entry); } } + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + if let Some(item_id) = custom_tool_call.id.clone() { + let call_id = custom_tool_call + .call_id + .clone() + .or_else(|| custom_tool_call.id.clone()) + .unwrap_or_else(|| item_id.clone()); + let entry = PendingResponseCustomToolCall { + call_id, + name: Arc::::from( + custom_tool_call.name.clone().unwrap_or_default(), + ), + input: custom_tool_call.input.clone(), + }; + self.custom_tool_calls_by_item.insert(item_id, entry); + } + } ResponseOutputItem::Compaction(_) => { events.push(Ok(LanguageModelCompletionEvent::Compaction( CompactionContent::Pending, @@ -814,7 +939,8 @@ impl OpenAiResponseEventMapper { } events } - ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => { + ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } + | ResponsesStreamEvent::ReasoningDelta { delta, .. } => { if delta.is_empty() { Vec::new() } else { @@ -847,7 +973,7 @@ impl OpenAiResponseEventMapper { id: LanguageModelToolUseId::from(entry.call_id.clone()), name: entry.name.clone(), is_input_complete: false, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -872,7 +998,7 @@ impl OpenAiResponseEventMapper { id: LanguageModelToolUseId::from(entry.call_id.clone()), name: entry.name.clone(), is_input_complete: true, - input, + input: LanguageModelToolUseInput::Json(input), raw_input, thought_signature: None, }, @@ -891,6 +1017,30 @@ impl OpenAiResponseEventMapper { Vec::new() } } + ResponsesStreamEvent::CustomToolCallInputDelta { item_id, delta, .. } => { + if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id) { + entry.input.push_str(&delta); + return vec![Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(entry.call_id.clone()), + name: entry.name.clone(), + is_input_complete: false, + input: LanguageModelToolUseInput::Text(entry.input.clone()), + raw_input: entry.input.clone(), + thought_signature: None, + }, + ))]; + } + Vec::new() + } + ResponsesStreamEvent::CustomToolCallInputDone { item_id, input, .. } => { + if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id) + && !input.is_empty() + { + entry.input = input; + } + self.finish_pending_custom_tool_call(&item_id, None) + } ResponsesStreamEvent::Completed { response } => { self.handle_completion(response, StopReason::EndTurn) } @@ -930,20 +1080,18 @@ impl OpenAiResponseEventMapper { events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason))); events } - ResponsesStreamEvent::Failed { response } => { - let message = response_failure_message(&response); - vec![Err(LanguageModelCompletionError::Other(anyhow!(message)))] - } + ResponsesStreamEvent::Failed { response } => match response.error.as_ref() { + Some(error) => vec![Err(completion_error_from_response_error(error))], + None => vec![Err(LanguageModelCompletionError::Other(anyhow!( + response_failure_message(&response) + )))], + }, ResponsesStreamEvent::Error { error } => { - vec![Err(LanguageModelCompletionError::Other(anyhow!( - response_error_message(&error) - )))] + vec![Err(completion_error_from_response_error(&error))] } ResponsesStreamEvent::GenericError { error } => { let error = error.into_response_error(); - vec![Err(LanguageModelCompletionError::Other(anyhow!( - response_error_message(&error) - )))] + vec![Err(completion_error_from_response_error(&error))] } ResponsesStreamEvent::ReasoningSummaryPartAdded { summary_index, .. } => { if summary_index > 0 { @@ -958,6 +1106,13 @@ impl OpenAiResponseEventMapper { ResponsesStreamEvent::OutputItemDone { item, .. } => match item { ResponseOutputItem::Reasoning(reasoning) => self.capture_reasoning_item(&reasoning), ResponseOutputItem::Message(message) => self.capture_message_phase(&message), + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + if let Some(item_id) = custom_tool_call.id.as_ref() { + self.finish_pending_custom_tool_call(item_id, Some(&custom_tool_call)) + } else { + Vec::new() + } + } ResponseOutputItem::Compaction(compaction) => { vec![Ok(LanguageModelCompletionEvent::Compaction( CompactionContent::Encrypted { @@ -973,6 +1128,7 @@ impl OpenAiResponseEventMapper { | ResponsesStreamEvent::ContentPartDone { .. } | ResponsesStreamEvent::ReasoningSummaryTextDone { .. } | ResponsesStreamEvent::ReasoningSummaryPartDone { .. } + | ResponsesStreamEvent::ReasoningDone { .. } | ResponsesStreamEvent::Created { .. } | ResponsesStreamEvent::InProgress { .. } | ResponsesStreamEvent::Unknown => Vec::new(), @@ -1013,48 +1169,109 @@ impl OpenAiResponseEventMapper { ) -> Vec> { let mut events = Vec::new(); for item in output { - if let ResponseOutputItem::FunctionCall(function_call) = item { - let Some(call_id) = function_call - .call_id - .clone() - .or_else(|| function_call.id.clone()) - else { - log::error!( - "Function call item missing both call_id and id: {:?}", - function_call - ); - continue; - }; - let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default()); - let arguments = &function_call.arguments; - self.pending_stop_reason = Some(StopReason::ToolUse); - match parse_tool_arguments(arguments) { - Ok(input) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { + match item { + ResponseOutputItem::FunctionCall(function_call) => { + let Some(call_id) = function_call + .call_id + .clone() + .or_else(|| function_call.id.clone()) + else { + log::error!( + "Function call item missing both call_id and id: {:?}", + function_call + ); + continue; + }; + let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default()); + let arguments = &function_call.arguments; + self.pending_stop_reason = Some(StopReason::ToolUse); + match parse_tool_arguments(arguments) { + Ok(input) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(call_id.clone()), + name: name.clone(), + is_input_complete: true, + input: LanguageModelToolUseInput::Json(input), + raw_input: arguments.clone(), + thought_signature: None, + }, + ))); + } + Err(error) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { id: LanguageModelToolUseId::from(call_id.clone()), - name: name.clone(), - is_input_complete: true, - input, - raw_input: arguments.clone(), - thought_signature: None, - }, - ))); - } - Err(error) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - id: LanguageModelToolUseId::from(call_id.clone()), - tool_name: name.clone(), - raw_input: Arc::::from(arguments.clone()), - json_parse_error: error.to_string(), - })); + tool_name: name.clone(), + raw_input: Arc::::from(arguments.clone()), + json_parse_error: error.to_string(), + })); + } } } + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + events.extend(self.emit_custom_tool_call(custom_tool_call)); + } + _ => {} } } events } + fn emit_custom_tool_call( + &mut self, + custom_tool_call: &crate::responses::ResponseCustomToolCall, + ) -> Vec> { + let Some(call_id) = custom_tool_call + .call_id + .clone() + .or_else(|| custom_tool_call.id.clone()) + else { + log::error!( + "Custom tool call item missing both call_id and id: {:?}", + custom_tool_call + ); + return Vec::new(); + }; + self.pending_stop_reason = Some(StopReason::ToolUse); + let input = custom_tool_call.input.clone(); + vec![Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(call_id), + name: Arc::from(custom_tool_call.name.clone().unwrap_or_default()), + is_input_complete: true, + input: LanguageModelToolUseInput::Text(input.clone()), + raw_input: input, + thought_signature: None, + }, + ))] + } + + fn finish_pending_custom_tool_call( + &mut self, + item_id: &str, + fallback: Option<&crate::responses::ResponseCustomToolCall>, + ) -> Vec> { + let Some(mut entry) = self.custom_tool_calls_by_item.remove(item_id) else { + return Vec::new(); + }; + if let Some(fallback) = fallback + && !fallback.input.is_empty() + { + entry.input = fallback.input.clone(); + } + self.pending_stop_reason = Some(StopReason::ToolUse); + vec![Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(entry.call_id), + name: entry.name, + is_input_complete: true, + input: LanguageModelToolUseInput::Text(entry.input.clone()), + raw_input: entry.input, + thought_signature: None, + }, + ))] + } + fn capture_reasoning_items_from_output( &mut self, output: &[ResponseOutputItem], @@ -1167,6 +1384,15 @@ fn response_failure_message(response: &ResponsesSummary) -> String { .unwrap_or_else(|| "response.failed".to_string()) } +fn completion_error_from_response_error(error: &ResponseError) -> LanguageModelCompletionError { + let message = response_error_message(error); + if is_context_window_exceeded_message(&message) { + LanguageModelCompletionError::PromptTooLarge { tokens: None } + } else { + LanguageModelCompletionError::Other(anyhow!(message)) + } +} + fn response_error_message(error: &ResponseError) -> String { let code = error.code.as_deref().filter(|code| !code.trim().is_empty()); let message = error.message.trim(); @@ -1243,15 +1469,17 @@ fn response_reasoning_input_item_from_output( #[cfg(test)] mod tests { use crate::responses::{ - ReasoningSummaryPart, ResponseError, ResponseFunctionToolCall, ResponseIncompleteDetails, - ResponseInputTokensDetails, ResponseOutputItem, ResponseOutputMessage, - ResponseReasoningItem, ResponseSummary, ResponseUsage, StreamEvent as ResponsesStreamEvent, + ReasoningSummaryPart, ResponseCustomToolCall, ResponseError, ResponseFunctionToolCall, + ResponseIncompleteDetails, ResponseInputItem, ResponseInputTokensDetails, + ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseSummary, + ResponseUsage, StreamEvent as ResponsesStreamEvent, ToolDefinition, }; use futures::{StreamExt, executor::block_on}; use language_model_core::{ - LanguageModelImage, LanguageModelRequestMessage, LanguageModelRequestTool, + LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage, + LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelRequestToolInput, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, - LanguageModelToolUseId, SharedString, Speed, + LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, Speed, }; use pretty_assertions::assert_eq; use serde_json::json; @@ -1304,6 +1532,16 @@ mod tests { }) } + fn response_item_custom_tool_call(id: &str, input: &str) -> ResponseOutputItem { + ResponseOutputItem::CustomToolCall(ResponseCustomToolCall { + id: Some(id.to_string()), + status: Some("in_progress".to_string()), + name: Some("apply_patch".to_string()), + call_id: Some("call_abc".to_string()), + input: input.to_string(), + }) + } + fn response_reasoning_item( id: &str, summary: Vec, @@ -1371,6 +1609,22 @@ mod tests { )); } + #[test] + fn responses_stream_maps_mantle_reasoning_delta() { + let event = serde_json::from_value::(json!({ + "type": "response.reasoning.delta", + "delta": "checking the contract terms" + })) + .unwrap(); + + let mapped = map_response_events(vec![event]); + assert!(matches!( + &mapped[0], + LanguageModelCompletionEvent::Thinking { text, signature: None } + if text == "checking the contract terms" + )); + } + #[test] fn response_usage_deserializes_cached_tokens() -> Result<()> { let usage: ResponseUsage = serde_json::from_value(json!({ @@ -1399,6 +1653,97 @@ mod tests { Ok(()) } + #[test] + fn responses_custom_tool_wire_types_round_trip() -> Result<()> { + let tool_json = json!({ + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/" + } + }); + let tool: ToolDefinition = serde_json::from_value(tool_json.clone())?; + assert_eq!(serde_json::to_value(tool)?, tool_json); + + let text_tool_json = json!({ + "type": "custom", + "name": "write_text", + "format": { "type": "text" } + }); + let text_tool: ToolDefinition = serde_json::from_value(text_tool_json.clone())?; + assert_eq!(serde_json::to_value(text_tool)?, text_tool_json); + + let input_json = json!({ + "type": "custom_tool_call", + "id": "ctc_1", + "call_id": "call_abc", + "name": "apply_patch", + "input": "*** Begin Patch\n*** End Patch" + }); + let input: ResponseInputItem = serde_json::from_value(input_json.clone())?; + assert_eq!(serde_json::to_value(input)?, input_json); + + let output_json = json!({ + "type": "custom_tool_call_output", + "call_id": "call_abc", + "output": "ok" + }); + let output: ResponseInputItem = serde_json::from_value(output_json.clone())?; + assert_eq!(serde_json::to_value(output)?, output_json); + + let output_item_json = json!({ + "id": "ctc_1", + "type": "custom_tool_call", + "status": "completed", + "call_id": "call_abc", + "name": "apply_patch", + "input": "*** Begin Patch\n*** End Patch" + }); + let output_item: ResponseOutputItem = serde_json::from_value(output_item_json.clone())?; + assert_eq!(serde_json::to_value(output_item)?, output_item_json); + + let delta_json = json!({ + "type": "response.custom_tool_call_input.delta", + "output_index": 0, + "item_id": "ctc_1", + "sequence_number": 5, + "delta": "chunk" + }); + let delta: ResponsesStreamEvent = serde_json::from_value(delta_json)?; + assert!(matches!( + delta, + ResponsesStreamEvent::CustomToolCallInputDelta { + output_index: 0, + sequence_number: Some(5), + ref item_id, + ref delta, + } if item_id == "ctc_1" && delta == "chunk" + )); + + let done_json = json!({ + "type": "response.custom_tool_call_input.done", + "output_index": 0, + "item_id": "ctc_1", + "sequence_number": 6, + "input": "full text" + }); + let done: ResponsesStreamEvent = serde_json::from_value(done_json)?; + assert!(matches!( + done, + ResponsesStreamEvent::CustomToolCallInputDone { + output_index: 0, + sequence_number: Some(6), + ref item_id, + ref input, + } if item_id == "ctc_1" && input == "full text" + )); + + Ok(()) + } + #[test] fn into_open_ai_response_builds_complete_payload() { let tool_call_id = LanguageModelToolUseId::from("call-42"); @@ -1408,7 +1753,7 @@ mod tests { id: tool_call_id.clone(), name: Arc::from("get_weather"), raw_input: tool_arguments.clone(), - input: tool_input, + input: LanguageModelToolUseInput::Json(tool_input), is_input_complete: true, thought_signature: None, }; @@ -1460,12 +1805,12 @@ mod tests { reasoning_details: None, }, ], - tools: vec![LanguageModelRequestTool { - name: "get_weather".into(), - description: "Fetches the weather".into(), - input_schema: json!({ "type": "object" }), - use_input_streaming: false, - }], + tools: vec![LanguageModelRequestTool::function( + "get_weather".into(), + "Fetches the weather".into(), + json!({ "type": "object" }), + false, + )], tool_choice: Some(LanguageModelToolChoice::Any), stop: vec!["".into()], temperature: None, @@ -1544,6 +1889,166 @@ mod tests { assert_eq!(serialized, expected); } + #[test] + fn responses_stream_maps_custom_tool_input() { + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: response_item_custom_tool_call("ctc_1", ""), + }, + ResponsesStreamEvent::CustomToolCallInputDelta { + item_id: "ctc_1".into(), + output_index: 0, + delta: "*** Begin".into(), + sequence_number: Some(1), + }, + ResponsesStreamEvent::CustomToolCallInputDelta { + item_id: "ctc_1".into(), + output_index: 0, + delta: " Patch".into(), + sequence_number: Some(2), + }, + ResponsesStreamEvent::CustomToolCallInputDone { + item_id: "ctc_1".into(), + output_index: 0, + input: "*** Begin Patch".into(), + sequence_number: Some(3), + }, + ResponsesStreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: response_item_custom_tool_call("ctc_1", "*** Begin Patch"), + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + assert_eq!( + mapped, + vec![ + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from("call_abc"), + name: Arc::from("apply_patch"), + raw_input: "*** Begin".into(), + input: LanguageModelToolUseInput::Text("*** Begin".into()), + is_input_complete: false, + thought_signature: None, + }), + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from("call_abc"), + name: Arc::from("apply_patch"), + raw_input: "*** Begin Patch".into(), + input: LanguageModelToolUseInput::Text("*** Begin Patch".into()), + is_input_complete: false, + thought_signature: None, + }), + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from("call_abc"), + name: Arc::from("apply_patch"), + raw_input: "*** Begin Patch".into(), + input: LanguageModelToolUseInput::Text("*** Begin Patch".into()), + is_input_complete: true, + thought_signature: None, + }), + LanguageModelCompletionEvent::Stop(StopReason::ToolUse), + ] + ); + } + + #[test] + fn into_open_ai_response_replays_custom_tool_calls() { + let tool_call_id = LanguageModelToolUseId::from("call_abc"); + let raw_input = "*** Begin Patch\n*** End Patch".to_string(); + let tool_use = LanguageModelToolUse { + id: tool_call_id.clone(), + name: Arc::from("apply_patch"), + raw_input: raw_input.clone(), + input: LanguageModelToolUseInput::Text(raw_input.clone()), + is_input_complete: true, + thought_signature: None, + }; + let tool_result = LanguageModelToolResult { + tool_use_id: tool_call_id, + tool_name: Arc::from("apply_patch"), + is_error: false, + content: vec![LanguageModelToolResultContent::Text(Arc::from("ok"))], + output: None, + }; + + let request = LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: None, + messages: vec![LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::ToolUse(tool_use), + MessageContent::ToolResult(tool_result), + ], + cache: false, + reasoning_details: None, + }], + tools: vec![LanguageModelRequestTool { + name: "apply_patch".into(), + description: "Apply a patch".into(), + input: LanguageModelRequestToolInput::Custom { + format: Some(LanguageModelCustomToolFormat::Grammar { + syntax: LanguageModelCustomToolGrammarSyntax::Lark, + definition: "start: /.+/".into(), + }), + }, + }], + tool_choice: None, + stop: Vec::new(), + temperature: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + compact_at_tokens: None, + }; + + let response = + into_open_ai_response(request, "custom-model", false, false, None, None, false); + let serialized = serde_json::to_value(response).unwrap(); + assert_eq!( + serialized, + json!({ + "model": "custom-model", + "input": [ + { + "type": "custom_tool_call", + "call_id": "call_abc", + "name": "apply_patch", + "input": raw_input + }, + { + "type": "custom_tool_call_output", + "call_id": "call_abc", + "output": "ok" + } + ], + "store": false, + "stream": true, + "parallel_tool_calls": false, + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/" + } + } + ] + }) + ); + } + #[test] fn into_open_ai_response_replays_encrypted_reasoning_details() { let tool_call_id = LanguageModelToolUseId::from("call-42"); @@ -1552,7 +2057,7 @@ mod tests { id: tool_call_id, name: Arc::from("get_weather"), raw_input: tool_arguments.clone(), - input: json!({ "city": "Boston" }), + input: LanguageModelToolUseInput::Json(json!({ "city": "Boston" })), is_input_complete: true, thought_signature: None, }; @@ -1832,7 +2337,7 @@ mod tests { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + )?; let serialized = serde_json::to_value(&chat)?; assert_eq!( @@ -1877,7 +2382,7 @@ mod tests { ChatCompletionMaxTokensParameter::MaxTokens, None, false, - ); + )?; let serialized = serde_json::to_value(&chat)?; assert_eq!(serialized.get("max_completion_tokens"), None); @@ -2348,8 +2853,8 @@ mod tests { "type": "error", "error": { "type": "invalid_request_error", - "code": "context_length_exceeded", - "message": "Your input exceeds the context window of this model. Please adjust your input and try again.", + "code": "invalid_prompt", + "message": "Your prompt was flagged.", "param": "input" }, "sequence_number": 2 @@ -2363,10 +2868,58 @@ mod tests { let error = mapped.into_iter().next().unwrap().unwrap_err(); assert_eq!( error.to_string(), - "context_length_exceeded: Your input exceeds the context window of this model. Please adjust your input and try again." + "invalid_prompt: Your prompt was flagged." ); } + #[test] + fn responses_stream_maps_context_length_exceeded_to_prompt_too_large() { + let event = serde_json::from_value::(json!({ + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "context_length_exceeded", + "message": "Your input exceeds the context window of this model. Please adjust your input and try again.", + "param": "input" + }, + "sequence_number": 2 + })) + .expect("nested error event"); + + let mut mapper = OpenAiResponseEventMapper::new(); + let mapped = mapper.map_event(event); + + assert_eq!(mapped.len(), 1); + let error = mapped.into_iter().next().unwrap().unwrap_err(); + assert!(matches!( + error, + LanguageModelCompletionError::PromptTooLarge { tokens: None } + )); + } + + #[test] + fn responses_stream_maps_failed_context_length_exceeded_to_prompt_too_large() { + let mut mapper = OpenAiResponseEventMapper::new(); + let mapped = mapper.map_event(ResponsesStreamEvent::Failed { + response: ResponseSummary { + status: Some("failed".into()), + error: Some(ResponseError { + code: Some("context_length_exceeded".into()), + message: "Your input exceeds the context window of this model.".into(), + param: Some("input".into()), + }), + ..Default::default() + }, + }); + + assert_eq!(mapped.len(), 1); + let error = mapped.into_iter().next().unwrap().unwrap_err(); + assert!(matches!( + error, + LanguageModelCompletionError::PromptTooLarge { tokens: None } + )); + } + #[test] fn responses_stream_deserializes_response_error_event() { let event = serde_json::from_value::(json!({ @@ -2682,8 +3235,7 @@ mod tests { }) if id.to_string() == "call_123" && name.as_ref() == "get_weather" && raw_input == "" - && input.is_object() - && input.as_object().unwrap().is_empty() + && matches!(input, LanguageModelToolUseInput::Json(value) if value.as_object().is_some_and(|object| object.is_empty())) )); assert!(matches!( mapped[1], @@ -3180,7 +3732,7 @@ mod tests { id: tool_use_id.clone(), name: Arc::from("search"), raw_input: tool_arguments.clone(), - input: tool_input, + input: LanguageModelToolUseInput::Json(tool_input), is_input_complete: true, thought_signature: None, }; @@ -3241,7 +3793,8 @@ mod tests { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, true, - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ @@ -3265,7 +3818,8 @@ mod tests { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index ec25a80d7e21c8..3f1502b9cb0da7 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -68,7 +68,6 @@ pub enum Model { #[serde(rename = "gpt-5")] Five, #[serde(rename = "gpt-5-mini")] - #[default] FiveMini, #[serde(rename = "gpt-5-nano")] FiveNano, @@ -90,6 +89,13 @@ pub enum Model { FivePointFive, #[serde(rename = "gpt-5.5-pro")] FivePointFivePro, + #[serde(rename = "gpt-5.6-sol")] + #[default] + FivePointSixSol, + #[serde(rename = "gpt-5.6-terra")] + FivePointSixTerra, + #[serde(rename = "gpt-5.6-luna")] + FivePointSixLuna, #[serde(rename = "custom")] Custom { name: String, @@ -116,7 +122,7 @@ const fn default_supports_images() -> bool { impl Model { pub fn default_fast() -> Self { - Self::FiveMini + Self::FivePointSixLuna } pub fn from_id(id: &str) -> Result { @@ -136,6 +142,9 @@ impl Model { "gpt-5.4-pro" => Ok(Self::FivePointFourPro), "gpt-5.5" => Ok(Self::FivePointFive), "gpt-5.5-pro" => Ok(Self::FivePointFivePro), + "gpt-5.6-sol" => Ok(Self::FivePointSixSol), + "gpt-5.6-terra" => Ok(Self::FivePointSixTerra), + "gpt-5.6-luna" => Ok(Self::FivePointSixLuna), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -157,6 +166,9 @@ impl Model { Self::FivePointFourPro => "gpt-5.4-pro", Self::FivePointFive => "gpt-5.5", Self::FivePointFivePro => "gpt-5.5-pro", + Self::FivePointSixSol => "gpt-5.6-sol", + Self::FivePointSixTerra => "gpt-5.6-terra", + Self::FivePointSixLuna => "gpt-5.6-luna", Self::Custom { name, .. } => name, } } @@ -178,6 +190,9 @@ impl Model { Self::FivePointFourPro => "gpt-5.4-pro", Self::FivePointFive => "gpt-5.5", Self::FivePointFivePro => "gpt-5.5-pro", + Self::FivePointSixSol => "gpt-5.6-sol", + Self::FivePointSixTerra => "gpt-5.6-terra", + Self::FivePointSixLuna => "gpt-5.6-luna", Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()), } } @@ -199,6 +214,9 @@ impl Model { Self::FivePointFourPro => 1_050_000, Self::FivePointFive => 1_050_000, Self::FivePointFivePro => 1_050_000, + Self::FivePointSixSol => 1_050_000, + Self::FivePointSixTerra => 1_050_000, + Self::FivePointSixLuna => 1_050_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -223,6 +241,9 @@ impl Model { Self::FivePointFourPro => Some(128_000), Self::FivePointFive => Some(128_000), Self::FivePointFivePro => Some(128_000), + Self::FivePointSixSol => Some(128_000), + Self::FivePointSixTerra => Some(128_000), + Self::FivePointSixLuna => Some(128_000), } } @@ -236,6 +257,7 @@ impl Model { | Self::FivePointFour | Self::FivePointFourMini | Self::FivePointFourNano => Some(ReasoningEffort::None), + Self::FivePointSixSol => Some(ReasoningEffort::Low), Self::O3 | Self::Five | Self::FiveMini @@ -243,7 +265,9 @@ impl Model { | Self::FivePointThreeCodex | Self::FivePointFourPro | Self::FivePointFive - | Self::FivePointFivePro => Some(ReasoningEffort::Medium), + | Self::FivePointFivePro + | Self::FivePointSixTerra + | Self::FivePointSixLuna => Some(ReasoningEffort::Medium), _ => None, } } @@ -290,6 +314,14 @@ impl Model { ReasoningEffort::High, ReasoningEffort::XHigh, ], + Self::FivePointSixSol | Self::FivePointSixTerra | Self::FivePointSixLuna => &[ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ], Self::FivePointTwo | Self::FivePointFour | Self::FivePointFive @@ -333,6 +365,9 @@ impl Model { | Self::FivePointFourPro | Self::FivePointFive | Self::FivePointFivePro + | Self::FivePointSixSol + | Self::FivePointSixTerra + | Self::FivePointSixLuna | Self::FiveNano => true, Self::O3 | Model::Custom { .. } => false, } @@ -360,7 +395,10 @@ impl Model { | Self::FivePointFour | Self::FivePointFourPro | Self::FivePointFive - | Self::FivePointFivePro => true, + | Self::FivePointFivePro + | Self::FivePointSixSol + | Self::FivePointSixTerra + | Self::FivePointSixLuna => true, Self::Four | Self::FourOmniMini | Self::O3 @@ -387,7 +425,10 @@ impl Model { | Self::FivePointThreeCodex | Self::FivePointFourMini | Self::FivePointFour - | Self::FivePointFive => true, + | Self::FivePointFive + | Self::FivePointSixSol + | Self::FivePointSixTerra + | Self::FivePointSixLuna => true, Self::Four | Self::FiveNano | Self::FivePointFourNano diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 647f9fcbacec78..f508a2746057da 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -66,6 +66,8 @@ pub enum ResponseInputItem { Message(ResponseMessageItem), FunctionCall(ResponseFunctionCallItem), FunctionCallOutput(ResponseFunctionCallOutputItem), + CustomToolCall(ResponseCustomToolCallItem), + CustomToolCallOutput(ResponseCustomToolCallOutputItem), Reasoning(ResponseReasoningInputItem), Compaction(ResponseCompactionItem), } @@ -98,6 +100,21 @@ pub struct ResponseFunctionCallOutputItem { pub output: ResponseFunctionCallOutputContent, } +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseCustomToolCallItem { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub call_id: String, + pub name: String, + pub input: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseCustomToolCallOutputItem { + pub call_id: String, + pub output: ResponseFunctionCallOutputContent, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ResponseReasoningInputItem { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -157,7 +174,7 @@ pub enum ReasoningSummaryMode { Detailed, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ToolDefinition { Function { @@ -169,9 +186,33 @@ pub enum ToolDefinition { #[serde(skip_serializing_if = "Option::is_none")] strict: Option, }, + Custom { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CustomToolFormat { + Text, + Grammar { + syntax: CustomToolGrammarSyntax, + definition: String, + }, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CustomToolGrammarSyntax { + Lark, + Regex, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseError { #[serde(default)] pub code: Option, @@ -310,6 +351,23 @@ pub enum StreamEvent { output_index: usize, summary_index: usize, }, + #[serde(rename = "response.reasoning.delta")] + ReasoningDelta { + #[serde(default)] + item_id: Option, + #[serde(default)] + output_index: Option, + delta: String, + }, + #[serde(rename = "response.reasoning.done")] + ReasoningDone { + #[serde(default)] + item_id: Option, + #[serde(default)] + output_index: Option, + #[serde(default)] + text: Option, + }, #[serde(rename = "response.function_call_arguments.delta")] FunctionCallArgumentsDelta { item_id: String, @@ -326,6 +384,22 @@ pub enum StreamEvent { #[serde(default)] sequence_number: Option, }, + #[serde(rename = "response.custom_tool_call_input.delta")] + CustomToolCallInputDelta { + item_id: String, + output_index: usize, + delta: String, + #[serde(default)] + sequence_number: Option, + }, + #[serde(rename = "response.custom_tool_call_input.done")] + CustomToolCallInputDone { + item_id: String, + output_index: usize, + input: String, + #[serde(default)] + sequence_number: Option, + }, #[serde(rename = "response.completed")] Completed { response: ResponseSummary }, #[serde(rename = "response.incomplete")] @@ -343,7 +417,7 @@ pub enum StreamEvent { Unknown, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseSummary { #[serde(default)] pub id: Option, @@ -361,13 +435,13 @@ pub struct ResponseSummary { pub service_tier: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseIncompleteDetails { #[serde(default)] pub reason: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseUsage { #[serde(default)] pub input_tokens: Option, @@ -381,30 +455,32 @@ pub struct ResponseUsage { pub total_tokens: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseInputTokensDetails { #[serde(default)] pub cached_tokens: u64, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseOutputTokensDetails { #[serde(default)] pub reasoning_tokens: u64, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseOutputItem { Message(ResponseOutputMessage), FunctionCall(ResponseFunctionToolCall), + CustomToolCall(ResponseCustomToolCall), Reasoning(ResponseReasoningItem), Compaction(ResponseCompactionItem), + /// Deserialization-only catch-all; must never be serialized back to the API. #[serde(other)] Unknown, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseReasoningItem { #[serde(default)] pub id: Option, @@ -418,7 +494,7 @@ pub struct ResponseReasoningItem { pub status: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningSummaryPart { SummaryText { @@ -428,7 +504,7 @@ pub enum ReasoningSummaryPart { Unknown, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseOutputMessage { #[serde(default)] pub id: Option, @@ -442,7 +518,7 @@ pub struct ResponseOutputMessage { pub phase: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseFunctionToolCall { #[serde(default)] pub id: Option, @@ -456,6 +532,20 @@ pub struct ResponseFunctionToolCall { pub status: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResponseCustomToolCall { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub call_id: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub input: String, +} + pub async fn stream_response( client: &dyn HttpClient, provider_name: &str, @@ -563,6 +653,16 @@ pub async fn stream_response( }); } } + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + if let Some(ref item_id) = custom_tool_call.id { + all_events.push(StreamEvent::CustomToolCallInputDone { + item_id: item_id.clone(), + output_index, + input: custom_tool_call.input.clone(), + sequence_number: None, + }); + } + } ResponseOutputItem::Reasoning(reasoning) => { if let Some(ref item_id) = reasoning.id { for part in &reasoning.summary { diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 6fc35e697c8cb4..5fa29d453acf0a 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -68,7 +68,7 @@ impl OpenPathDelegate { cancel_flag: Arc::new(AtomicBool::new(false)), should_dismiss: true, prompt_root: match path_style { - PathStyle::Posix => "/".to_string(), + PathStyle::Unix => "/".to_string(), PathStyle::Windows => "C:\\".to_string(), }, path_style, @@ -158,7 +158,7 @@ impl OpenPathDelegate { fn current_dir(&self) -> &'static str { match self.path_style { - PathStyle::Posix => "./", + PathStyle::Unix => "./", PathStyle::Windows => ".\\", } } @@ -929,7 +929,7 @@ fn path_candidates( fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) { match path_style { - PathStyle::Posix => { + PathStyle::Unix => { let (mut dir, suffix) = if let Some(index) = query.rfind('/') { (query[..index].to_string(), query[index + 1..].to_string()) } else { @@ -1028,39 +1028,39 @@ mod tests { #[test] fn test_get_dir_and_suffix_with_posix_style() { - let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Unix); assert_eq!(dir, "/"); assert_eq!(suffix, ""); - let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Unix); assert_eq!(dir, "/"); assert_eq!(suffix, ""); - let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Unix); assert_eq!(dir, "/"); assert_eq!(suffix, "Use"); - let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Unix); assert_eq!(dir, "/Users/Junkui/"); assert_eq!(suffix, "Docum"); - let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Unix); assert_eq!(dir, "/Users/Junkui/"); assert_eq!(suffix, "Documents"); - let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Unix); assert_eq!(dir, "/Users/Junkui/Documents/"); assert_eq!(suffix, ""); - let (dir, suffix) = get_dir_and_suffix("/root/.".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/root/.".into(), PathStyle::Unix); assert_eq!(dir, "/root/"); assert_eq!(suffix, "."); - let (dir, suffix) = get_dir_and_suffix("/root/..".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/root/..".into(), PathStyle::Unix); assert_eq!(dir, "/root/"); assert_eq!(suffix, ".."); - let (dir, suffix) = get_dir_and_suffix("/root/.hidden".into(), PathStyle::Posix); + let (dir, suffix) = get_dir_and_suffix("/root/.hidden".into(), PathStyle::Unix); assert_eq!(dir, "/root/"); assert_eq!(suffix, ".hidden"); } diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index ab030721e35ab7..9558bc8f24b8e5 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -77,8 +77,18 @@ pub enum Model { ClaudeSonnet4, #[serde(rename = "claude-haiku-4-5")] ClaudeHaiku4_5, + #[serde(rename = "claude-sonnet-5")] + ClaudeSonnet5, + #[serde(rename = "claude-fable-5")] + ClaudeFable5, // -- OpenAI Responses API models -- + #[serde(rename = "gpt-5.6-sol")] + Gpt5_6Sol, + #[serde(rename = "gpt-5.6-terra")] + Gpt5_6Terra, + #[serde(rename = "gpt-5.6-luna")] + Gpt5_6Luna, #[serde(rename = "gpt-5.5")] Gpt5_5, #[serde(rename = "gpt-5.5-pro")] @@ -137,6 +147,8 @@ pub enum Model { Glm5_2, #[serde(rename = "grok-build-0.1")] GrokBuild0_1, + #[serde(rename = "grok-4.5")] + Grok4_5, #[serde(rename = "kimi-k2.5")] KimiK2_5, #[serde(rename = "kimi-k2.6")] @@ -203,23 +215,24 @@ impl Model { match self { // Models available in both Zen and Go Self::Glm5_1 + | Self::Glm5_2 | Self::KimiK2_6 + | Self::KimiK2_7Code | Self::MiniMaxM2_7 + | Self::MiniMaxM3 | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::Qwen3_6Plus => &[OpenCodeSubscription::Zen, OpenCodeSubscription::Go], // Go-only models - Self::MimoV2_5Pro - | Self::MimoV2_5 - | Self::Qwen3_7Plus - | Self::Qwen3_7Max - | Self::KimiK2_7Code - | Self::Glm5_2 - | Self::MiniMaxM3 => &[OpenCodeSubscription::Go], + Self::MimoV2_5Pro | Self::MimoV2_5 | Self::Qwen3_7Plus | Self::Qwen3_7Max => { + &[OpenCodeSubscription::Go] + } // Deprecated on Go (per models.dev); still offered on Zen - Self::Glm5 | Self::KimiK2_5 | Self::MiniMaxM2_5 => &[OpenCodeSubscription::Zen], + Self::Glm5 | Self::KimiK2_5 | Self::MiniMaxM2_5 | Self::Qwen3_5Plus => { + &[OpenCodeSubscription::Zen] + } // Free models Self::Nemotron3UltraFree | Self::BigPickle => &[OpenCodeSubscription::Free], @@ -243,7 +256,12 @@ impl Model { Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", Self::ClaudeSonnet4 => "claude-sonnet-4", Self::ClaudeHaiku4_5 => "claude-haiku-4-5", + Self::ClaudeSonnet5 => "claude-sonnet-5", + Self::ClaudeFable5 => "claude-fable-5", + Self::Gpt5_6Sol => "gpt-5.6-sol", + Self::Gpt5_6Terra => "gpt-5.6-terra", + Self::Gpt5_6Luna => "gpt-5.6-luna", Self::Gpt5_5 => "gpt-5.5", Self::Gpt5_5Pro => "gpt-5.5-pro", Self::Gpt5_4 => "gpt-5.4", @@ -273,6 +291,7 @@ impl Model { Self::Glm5_1 => "glm-5.1", Self::Glm5_2 => "glm-5.2", Self::GrokBuild0_1 => "grok-build-0.1", + Self::Grok4_5 => "grok-4.5", Self::KimiK2_5 => "kimi-k2.5", Self::KimiK2_6 => "kimi-k2.6", Self::KimiK2_7Code => "kimi-k2.7-code", @@ -302,7 +321,12 @@ impl Model { Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", + Self::ClaudeSonnet5 => "Claude Sonnet 5", + Self::ClaudeFable5 => "Claude Fable 5", + Self::Gpt5_6Sol => "GPT 5.6 Sol", + Self::Gpt5_6Terra => "GPT 5.6 Terra", + Self::Gpt5_6Luna => "GPT 5.6 Luna", Self::Gpt5_5 => "GPT 5.5", Self::Gpt5_5Pro => "GPT 5.5 Pro", Self::Gpt5_4 => "GPT 5.4", @@ -332,6 +356,7 @@ impl Model { Self::Glm5_1 => "GLM 5.1", Self::Glm5_2 => "GLM 5.2", Self::GrokBuild0_1 => "Grok Build 0.1", + Self::Grok4_5 => "Grok 4.5", Self::KimiK2_5 => "Kimi K2.5", Self::KimiK2_6 => "Kimi K2.6", Self::KimiK2_7Code => "Kimi K2.7 Code", @@ -356,7 +381,7 @@ impl Model { match self { // Models offered by OpenCode have the same configuration across subscriptions // with one outlier: non-free MiniMax models - Self::MiniMaxM2_7 | Self::MiniMaxM2_5 => { + Self::MiniMaxM3 | Self::MiniMaxM2_7 | Self::MiniMaxM2_5 => { if subscription == OpenCodeSubscription::Zen { ApiProtocol::OpenAiChat } else { @@ -364,17 +389,22 @@ impl Model { } } - Self::ClaudeOpus4_8 + Self::ClaudeFable5 + | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 | Self::ClaudeHaiku4_5 => ApiProtocol::Anthropic, - Self::Gpt5_5 + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 | Self::Gpt5_5Pro | Self::Gpt5_4 | Self::Gpt5_4Pro @@ -394,21 +424,20 @@ impl Model { Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => ApiProtocol::Google, - Self::Qwen3_7Max | Self::Qwen3_7Plus => ApiProtocol::Anthropic, - - Self::MiniMaxM3 => ApiProtocol::Anthropic, + Self::Qwen3_7Max | Self::Qwen3_7Plus | Self::Qwen3_6Plus | Self::Qwen3_5Plus => { + ApiProtocol::Anthropic + } Self::Glm5 | Self::Glm5_1 | Self::Glm5_2 | Self::GrokBuild0_1 + | Self::Grok4_5 | Self::KimiK2_5 | Self::KimiK2_6 | Self::KimiK2_7Code | Self::MimoV2_5Pro | Self::MimoV2_5 - | Self::Qwen3_5Plus - | Self::Qwen3_6Plus | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::BigPickle @@ -432,6 +461,7 @@ impl Model { | Self::Glm5_2 | Self::MiniMaxM2_5 | Self::MiniMaxM2_7 + | Self::MiniMaxM3 | Self::Nemotron3UltraFree | Self::BigPickle => true, @@ -453,8 +483,11 @@ impl Model { Self::ClaudeOpus4_5 | Self::ClaudeHaiku4_5 => 200_000, Self::ClaudeOpus4_1 => 200_000, Self::ClaudeSonnet4 => 1_000_000, + Self::ClaudeSonnet5 => 1_000_000, + Self::ClaudeFable5 => 1_000_000, // OpenAI models + Self::Gpt5_6Sol | Self::Gpt5_6Terra | Self::Gpt5_6Luna => 1_050_000, Self::Gpt5_5 | Self::Gpt5_5Pro => 1_050_000, Self::Gpt5_4 | Self::Gpt5_4Pro => 1_050_000, Self::Gpt5_4Mini | Self::Gpt5_4Nano => 400_000, @@ -473,7 +506,13 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => 204_800, - Self::MiniMaxM3 => 512_000, + Self::MiniMaxM3 => { + if subscription == OpenCodeSubscription::Go { + 1_000_000 + } else { + 512_000 + } + } Self::MiniMaxM2_5 => 204_800, Self::Glm5 | Self::Glm5_1 => { if subscription == OpenCodeSubscription::Go { @@ -485,6 +524,7 @@ impl Model { Self::Glm5_2 => 1_000_000, Self::KimiK2_6 | Self::KimiK2_5 | Self::KimiK2_7Code => 262_144, Self::GrokBuild0_1 => 256_000, + Self::Grok4_5 => 500_000, Self::MimoV2_5Pro => 1_048_576, Self::MimoV2_5 => 1_000_000, Self::Qwen3_5Plus => 262_144, @@ -514,9 +554,14 @@ impl Model { | Self::ClaudeHaiku4_5 | Self::ClaudeSonnet4 => Some(64_000), Self::ClaudeOpus4_1 => Some(32_000), + Self::ClaudeSonnet5 => Some(128_000), + Self::ClaudeFable5 => Some(128_000), // OpenAI models - Self::Gpt5_5 + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 | Self::Gpt5_5Pro | Self::Gpt5_4 | Self::Gpt5_4Pro @@ -539,7 +584,13 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => Some(131_072), - Self::MiniMaxM3 => Some(131_072), + Self::MiniMaxM3 => { + if subscription == OpenCodeSubscription::Go { + Some(131_072) + } else { + Some(128_000) + } + } Self::MiniMaxM2_5 => { if subscription == OpenCodeSubscription::Go { Some(65_536) @@ -559,6 +610,7 @@ impl Model { Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536), Self::KimiK2_7Code => Some(262_144), Self::GrokBuild0_1 => Some(256_000), + Self::Grok4_5 => Some(500_000), Self::Qwen3_7Max | Self::Qwen3_7Plus | Self::Qwen3_6Plus | Self::Qwen3_5Plus => { Some(65_536) } @@ -587,10 +639,15 @@ impl Model { | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 - | Self::ClaudeHaiku4_5 => true, + | Self::ClaudeHaiku4_5 + | Self::ClaudeSonnet5 + | Self::ClaudeFable5 => true, // OpenAI models support images - Self::Gpt5_5 + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 | Self::Gpt5_5Pro | Self::Gpt5_4 | Self::Gpt5_4Pro @@ -618,6 +675,7 @@ impl Model { | Self::KimiK2_7Code | Self::KimiK2_5 | Self::GrokBuild0_1 + | Self::Grok4_5 | Self::MimoV2_5 | Self::Qwen3_5Plus | Self::Qwen3_6Plus @@ -649,20 +707,80 @@ impl Model { pub fn supported_reasoning_effort_levels(&self) -> Option> { match self { - Self::ClaudeOpus4_8 => Some(vec![ + // Anthropic models + Self::ClaudeFable5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeSonnet5 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::XHigh, + ReasoningEffort::Max, ]), - Self::Nemotron3UltraFree | Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ + Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, + ReasoningEffort::Max, ]), - Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![ + Self::ClaudeOpus4_5 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // OpenAI models + Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Gpt5_4Mini + | Self::Gpt5_4Nano + | Self::Gpt5_3Codex + | Self::Gpt5_2 => Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + + Self::Gpt5_5Pro | Self::Gpt5_4Pro => Some(vec![ + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + + Self::Gpt5_2Codex | Self::Gpt5_3Spark | Self::Gpt5_1CodexMax => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + + Self::Gpt5_1 => Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + Self::Gpt5Codex | Self::Gpt5_1Codex | Self::Gpt5_1CodexMini => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + Self::Gpt5 | Self::Gpt5Nano => Some(vec![ + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + Self::Gpt5_6Sol | Self::Gpt5_6Terra | Self::Gpt5_6Luna => Some(vec![ + ReasoningEffort::None, ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, @@ -670,6 +788,54 @@ impl Model { ReasoningEffort::Max, ]), + // Google models + Self::Gemini3Flash | Self::Gemini3_5Flash => Some(vec![ + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + Self::Gemini3_1Pro => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // DeepSeek models + Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![ + // OpenCode also supports Low&Medium but as per DeepSeek those are mapped to High + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + + // MiniMax models + Self::MiniMaxM3 => Some(vec![ReasoningEffort::None]), + + // NVIDIA models + Self::Nemotron3UltraFree => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // Xiaomi MiMo models + Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // Z AI models + Self::Glm5_2 => Some(vec![ReasoningEffort::High, ReasoningEffort::Max]), + + // SpaceXAI models + Self::Grok4_5 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + Self::Custom { reasoning_effort_levels, .. diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 8b49b5a2870ec5..ba661b36f98adb 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -18,6 +18,7 @@ fuzzy_nucleo.workspace = true gpui.workspace = true language.workspace = true picker.workspace = true +picker_preview.workspace = true settings.workspace = true theme.workspace = true theme_settings.workspace = true diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index ffd11e3197c3aa..0f22226f89aa6d 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -10,8 +10,8 @@ use gpui::{ ParentElement, Point, Rems, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, div, rems, }; -use language::{Outline, OutlineItem, OutlineSearchEntry}; -use picker::{Picker, PickerDelegate}; +use language::{OffsetRangeExt, Outline, OutlineItem, OutlineSearchEntry}; +use picker::{MatchLocation, Picker, PickerDelegate, PreviewUpdate}; use settings::Settings; use theme::ActiveTheme; use theme_settings::ThemeSettings; @@ -176,9 +176,16 @@ impl OutlineView { window: &mut Window, cx: &mut Context, ) -> OutlineView { + let project = editor.read(cx).project().cloned(); let delegate = OutlineViewDelegate::new(cx.entity().downgrade(), outline, editor, cx); let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) + let picker = if let Some(project) = project { + let preview = picker_preview::editor_preview(project, window, cx); + Picker::uniform_list_with_preview(delegate, preview, window, cx) + } else { + Picker::uniform_list(delegate, window, cx) + }; + picker .max_height(Rems::from_pixels( window.viewport_size().height * 0.75, window, @@ -293,6 +300,30 @@ impl PickerDelegate for OutlineViewDelegate { self.set_selected_index(ix, true, cx); } + fn try_get_preview_data_for_match(&self, cx: &App) -> Option { + let selected_match = self.matches.get(self.selected_match_index)?; + let outline_item = self.outline.items.get(selected_match.candidate_id())?; + let multi_buffer = self.active_editor.read(cx).buffer().clone(); + let (buffer, start) = multi_buffer + .read(cx) + .text_anchor_for_position(outline_item.selection_range.start, cx)?; + let (end_buffer, end) = multi_buffer + .read(cx) + .text_anchor_for_position(outline_item.selection_range.end, cx)?; + if buffer != end_buffer { + return None; + } + + let range = (start..end).to_offset(&buffer.read(cx).text_snapshot()); + Some(PreviewUpdate::from_buffer( + buffer, + MatchLocation { + anchor_range: start..end, + range, + }, + )) + } + fn update_matches( &mut self, query: String, diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 5da4af2655f2fe..62d5038989b815 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -744,7 +744,7 @@ impl OutlinePanel { cx, ); } - } else { + } else if !active_item_is_hosted_panel(workspace.read(cx), cx) { outline_panel.clear_previous(window, cx); cx.notify(); } @@ -3447,10 +3447,8 @@ impl OutlinePanel { }; let fetched_outlines = outline_task.await; let outlines_with_children = fetched_outlines - .windows(2) - .filter_map(|window| { - let current = &window[0]; - let next = &window[1]; + .array_windows::<2>() + .filter_map(|[current, next]| { if next.depth > current.depth { Some((current.range.clone(), current.depth)) } else { @@ -4748,7 +4746,7 @@ impl OutlinePanel { } }) .with_render_fn(cx.entity(), move |outline_panel, params, _, _| { - const LEFT_OFFSET: Pixels = px(14.); + const LEFT_OFFSET: Pixels = ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET; let indent_size = params.indent_size; let item_height = params.item_height; @@ -4915,6 +4913,17 @@ fn workspace_active_editor( Some((active_item, active_editor)) } +// Panels are hosted as pane items, so focusing a panel makes its wrapper item +// the workspace's active item. Keep showing the outline for the last active +// editor in that case instead of clearing the panel. +fn active_item_is_hosted_panel(workspace: &Workspace, cx: &App) -> bool { + workspace.active_item(cx).is_some_and(|item| { + item.to_any_view() + .downcast::() + .is_ok() + }) +} + fn back_to_common_visited_parent( visited_dirs: &mut Vec<(ProjectEntryId, Arc)>, worktree_id: &WorktreeId, @@ -5029,7 +5038,11 @@ impl Panel for OutlinePanel { return; } - if !outline_panel.pinned { + if !outline_panel.pinned + && !outline_panel.workspace.upgrade().is_some_and(|workspace| { + active_item_is_hosted_panel(workspace.read(cx), cx) + }) + { outline_panel.clear_previous(window, cx); } } diff --git a/crates/path/Cargo.toml b/crates/path/Cargo.toml new file mode 100644 index 00000000000000..73136332b0da95 --- /dev/null +++ b/crates/path/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "path" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +path = "src/path.rs" + +[features] +test-support = [] + +[dependencies] +anyhow.workspace = true +dunce.workspace = true +serde = { workspace = true, optional = true } + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/path/LICENSE-APACHE b/crates/path/LICENSE-APACHE new file mode 120000 index 00000000000000..1cd601d0a3affa --- /dev/null +++ b/crates/path/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/path/src/abs_path.rs b/crates/path/src/abs_path.rs new file mode 100644 index 00000000000000..65f5c2832c5abd --- /dev/null +++ b/crates/path/src/abs_path.rs @@ -0,0 +1,286 @@ +use std::{ + borrow::{Borrow, Cow}, + fmt, io, + ops::Deref, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; + +use anyhow::Context; + +use crate::{PathStyle, rel_path::RelPath}; + +// An absolute path on the user's local filesystem. +// Requires paths to be valid utf-8 +#[derive(PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] +#[repr(transparent)] +pub struct AbsPath(Path); + +impl AbsPath { + pub fn new(path: &Path) -> anyhow::Result<&Self> { + if !path.is_absolute() { + return Err(anyhow::anyhow!("Path is not absolute: {:?}", path)); + } + if path.to_str().is_none() { + return Err(anyhow::anyhow!("Path is not valid utf-8: {:?}", path)); + } + Ok(Self::new_unchecked(path)) + } + + fn new_unchecked(path: &Path) -> &Self { + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. + unsafe { &*(path as *const Path as *const Self) } + } + + pub fn to_abs_path_buf(&self) -> AbsPathBuf { + AbsPathBuf(self.0.to_owned()) + } + + pub fn join(&self, name: impl AsRef) -> AbsPathBuf { + AbsPathBuf(self.0.join(name.as_ref())) + } + + pub fn join_rel_path(&self, relative_path: &RelPath) -> AbsPathBuf { + AbsPathBuf(self.0.join(relative_path.as_std_path())) + } + + pub fn parent(&self) -> Option<&AbsPath> { + let parent = self.0.parent()?; + Some(AbsPath::new_unchecked(parent)) + } + + pub fn starts_with(&self, other: &AbsPath) -> bool { + self.0.starts_with(&other.0) + } + + pub fn ends_with(&self, other: &RelPath) -> bool { + self.0.ends_with(other.as_std_path()) + } + + pub fn is_descendant_of(&self, ancestor: &Self) -> bool { + if self == ancestor { + return false; + } + self.starts_with(ancestor) + } + + pub fn file_name(&self) -> Option<&str> { + self.0.file_name()?.to_str() + } + + pub fn display(&self) -> impl fmt::Display + '_ { + self.0.display() + } + + pub fn as_std_path(&self) -> &Path { + &self.0 + } + + pub fn as_str(&self) -> &str { + self.0 + .to_str() + .expect("valid UTF-8 enforced in constructor") + } + + pub fn ancestors(&self) -> impl Iterator { + self.0.ancestors().map(|p| AbsPath::new_unchecked(p)) + } + + pub fn strip_prefix<'a>(&'a self, prefix: &AbsPath) -> Option> { + let prefix = self.0.strip_prefix(&prefix.0).ok()?; + RelPath::new(prefix, PathStyle::local()).ok() + } +} + +impl ToOwned for AbsPath { + type Owned = AbsPathBuf; + + fn to_owned(&self) -> Self::Owned { + self.to_abs_path_buf() + } +} + +impl AsRef for AbsPath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl AsRef for AbsPath { + fn as_ref(&self) -> &AbsPath { + self + } +} + +impl From<&AbsPath> for Arc { + fn from(path: &AbsPath) -> Self { + let arc: Arc = Arc::from(&path.0); + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. + unsafe { Arc::from_raw(Arc::into_raw(arc) as *const AbsPath) } + } +} + +impl From<&AbsPath> for Rc { + fn from(path: &AbsPath) -> Self { + let arc: Rc = Rc::from(&path.0); + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. + unsafe { Rc::from_raw(Rc::into_raw(arc) as *const AbsPath) } + } +} + +// An absolute path on the user's local filesystem. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AbsPathBuf(PathBuf); + +impl AbsPathBuf { + pub fn new(path: impl Into) -> anyhow::Result { + let path = path.into(); + if let Err(e) = AbsPath::new(&path) { + return Err(e); + } + Ok(Self(path)) + } + + pub fn home_dir() -> anyhow::Result { + let home = std::env::home_dir().context("no home dir available")?; + Self::new(home) + } + + /// Resolves `path` to its canonical on-disk spelling: symlinks and `..` + /// are resolved, relative input is anchored on the current directory, and + /// on case-insensitive filesystems each component takes the casing stored + /// on disk. Unlike [`std::fs::canonicalize`], the result never uses + /// Windows extended-length (`\\?\`) syntax, which chokes tools the path + /// is later handed to (e.g. `git`). + /// + /// Paths act as identity in several places (lock keys, watch-target + /// comparisons, persisted repository records), so canonicalize a path + /// where it enters the system whenever it may have been spelled by a user + /// or an external tool. + pub fn canonicalize(path: impl AsRef) -> io::Result { + let canonical = dunce::canonicalize(path.as_ref())?; + Self::new(canonical).map_err(io::Error::other) + } + + pub fn push(&mut self, name: &str) { + self.0.push(name); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn new_test(path: &'static str) -> Self { + if cfg!(windows) { + Self::new(format!("C:{path}")).unwrap() + } else { + Self::new(path).unwrap() + } + } +} + +#[cfg(any(test, feature = "test-support"))] +pub fn abs_path(path: &str) -> AbsPathBuf { + if cfg!(windows) { + AbsPathBuf::new(format!("C:{path}")).unwrap() + } else { + AbsPathBuf::new(path).unwrap() + } +} + +impl Deref for AbsPathBuf { + type Target = AbsPath; + + fn deref(&self) -> &Self::Target { + AbsPath::new_unchecked(&self.0) + } +} + +impl Borrow for AbsPathBuf { + fn borrow(&self) -> &AbsPath { + self + } +} + +impl AsRef for AbsPathBuf { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} + +impl AsRef for AbsPathBuf { + fn as_ref(&self) -> &AbsPath { + self + } +} + +impl From for PathBuf { + fn from(path: AbsPathBuf) -> PathBuf { + path.0 + } +} + +impl fmt::Display for AbsPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_str().fmt(f) + } +} + +impl PartialEq for AbsPathBuf { + fn eq(&self, other: &AbsPath) -> bool { + **self == *other + } +} + +impl PartialEq for AbsPath { + fn eq(&self, other: &AbsPathBuf) -> bool { + *self == **other + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_canonicalize_restores_on_disk_spelling() { + let temp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp.path()).unwrap(); + let dir = root.join("CamelCase"); + std::fs::create_dir(&dir).unwrap(); + + let canonical = AbsPathBuf::canonicalize(&dir).unwrap(); + assert_eq!(canonical.as_std_path(), dir); + assert!( + !canonical.as_str().starts_with(r"\\?\"), + "canonical paths must not use Windows extended-length syntax: {canonical}" + ); + + // A differently-cased spelling addresses the same directory only on a + // case-insensitive filesystem; when it does, canonicalization must + // restore the stored casing. + let lowercased = root.join("camelcase"); + if std::fs::metadata(&lowercased).is_ok() { + assert_eq!( + AbsPathBuf::canonicalize(&lowercased).unwrap().as_std_path(), + dir, + "canonicalization should restore the on-disk casing" + ); + } + } + + #[test] + fn test_new_test_normalizes_rooted_paths() { + if cfg!(windows) { + assert_eq!(AbsPathBuf::new_test("/").as_str(), "C:/"); + assert_eq!( + AbsPathBuf::new_test("/test/project").as_str(), + "C:/test/project" + ); + } else { + assert_eq!(AbsPathBuf::new_test("/").as_str(), "/"); + assert_eq!( + AbsPathBuf::new_test("/test/project").as_str(), + "/test/project" + ); + } + } +} diff --git a/crates/path/src/path.rs b/crates/path/src/path.rs new file mode 100644 index 00000000000000..c6a063264a31a5 --- /dev/null +++ b/crates/path/src/path.rs @@ -0,0 +1,263 @@ +//! Relative path types for deltadb. +//! +//! Provides [`RelPath`] and [`RelPathBuf`] — path types that are guaranteed to be +//! relative, normalized, and valid unicode. Internally stored in POSIX (`/`-delimited) +//! format regardless of host platform. +//! +//! Adapted from Zed's `util::rel_path` module. + +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +use crate::rel_path::RelPath; + +pub mod abs_path; +pub mod rel_path; + +pub trait PathExt { + fn to_rel_path_buf(&self) -> anyhow::Result; +} + +impl + ?Sized> PathExt for T { + fn to_rel_path_buf(&self) -> anyhow::Result { + Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PathStyle { + Unix, + Windows, +} + +impl PathStyle { + #[cfg(target_os = "windows")] + pub const fn local() -> Self { + PathStyle::Windows + } + + #[cfg(not(target_os = "windows"))] + pub const fn local() -> Self { + PathStyle::Unix + } + + #[inline] + pub fn primary_separator(&self) -> &'static str { + match self { + PathStyle::Unix => "/", + PathStyle::Windows => "\\", + } + } + + pub fn separators(&self) -> &'static [&'static str] { + match self { + PathStyle::Unix => &["/"], + PathStyle::Windows => &["\\", "/"], + } + } + + pub fn separators_ch(&self) -> &'static [char] { + match self { + PathStyle::Unix => &['/'], + PathStyle::Windows => &['\\', '/'], + } + } + + pub fn is_absolute(&self, path_like: &str) -> bool { + path_like.starts_with('/') + || *self == PathStyle::Windows + && (path_like.starts_with('\\') + || path_like + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic()) + && path_like[1..] + .strip_prefix(':') + .is_some_and(|path| path.starts_with('/') || path.starts_with('\\'))) + } + + pub fn is_windows(&self) -> bool { + *self == PathStyle::Windows + } + + pub fn is_posix(&self) -> bool { + *self == PathStyle::Unix + } + + pub fn join(self, left: impl AsRef, right: impl AsRef) -> Option { + let right = right.as_ref().to_str()?; + if is_absolute(right, self) { + return None; + } + let left = left.as_ref().to_str()?; + if left.is_empty() { + Some(right.into()) + } else { + Some(format!( + "{left}{}{right}", + if left.ends_with(self.primary_separator()) { + "" + } else { + self.primary_separator() + } + )) + } + } + + pub fn join_path( + self, + left: impl AsRef, + right: impl AsRef, + ) -> anyhow::Result { + let left = left + .as_ref() + .to_str() + .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?; + let right = right.as_ref(); + let right_string = right + .to_str() + .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?; + let joined = self + .join(left, right_string) + .ok_or_else(|| anyhow::anyhow!("Path must be relative: {right:?}"))?; + Ok(PathBuf::from(self.normalize(&joined))) + } + + pub fn normalize(self, path_like: &str) -> String { + match self { + PathStyle::Windows => crate::normalize_path(Path::new(path_like)) + .to_string_lossy() + .into_owned(), + PathStyle::Unix => { + let is_absolute = path_like.starts_with('/'); + let remainder = if is_absolute { + path_like.trim_start_matches('/') + } else { + path_like + }; + + let mut components = Vec::new(); + for component in remainder.split(self.separators_ch()) { + match component { + "" | "." => {} + ".." => { + if components + .last() + .is_some_and(|component| *component != "..") + { + components.pop(); + } else if !is_absolute { + components.push(component); + } + } + component => components.push(component), + } + } + + let normalized = components.join(self.primary_separator()); + if is_absolute && normalized.is_empty() { + "/".to_string() + } else if is_absolute { + format!("/{normalized}") + } else { + normalized + } + } + } + } + + pub fn split(self, path_like: &str) -> (Option<&str>, &str) { + let Some(pos) = path_like.rfind(self.primary_separator()) else { + return (None, path_like); + }; + let filename_start = pos + self.primary_separator().len(); + ( + Some(&path_like[..filename_start]), + &path_like[filename_start..], + ) + } + + pub fn strip_prefix<'a>( + &self, + child: &'a Path, + parent: &'a Path, + ) -> Option> { + let parent = parent.to_str()?; + if parent.is_empty() { + return RelPath::new(child, *self).ok(); + } + let parent = self + .separators() + .iter() + .find_map(|sep| parent.strip_suffix(sep)) + .unwrap_or(parent); + let child = child.to_str()?; + + // Match behavior of std::path::Path, which is case-insensitive for drive letters (e.g., "C:" == "c:") + let stripped = if self.is_windows() + && child.as_bytes().get(1) == Some(&b':') + && parent.as_bytes().get(1) == Some(&b':') + && child.as_bytes()[0].eq_ignore_ascii_case(&parent.as_bytes()[0]) + { + child[2..].strip_prefix(&parent[2..])? + } else { + child.strip_prefix(parent)? + }; + if let Some(relative) = self + .separators() + .iter() + .find_map(|sep| stripped.strip_prefix(sep)) + { + RelPath::new(relative.as_ref(), *self).ok() + } else if stripped.is_empty() { + Some(Cow::Borrowed(RelPath::empty())) + } else { + None + } + } +} + +fn is_absolute(path_like: &str, path_style: PathStyle) -> bool { + path_like.starts_with('/') + || path_style == PathStyle::Windows + && (path_like.starts_with('\\') + || path_like + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic()) + && path_like[1..] + .strip_prefix(':') + .is_some_and(|path| path.starts_with('/') || path.starts_with('\\'))) +} + +/// Normalizes a path by resolving `.` and `..` components without +/// requiring the path to exist on disk (unlike `canonicalize`). +pub fn normalize_path(path: &Path) -> PathBuf { + use std::path::Component; + let mut components = path.components().peekable(); + let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { + components.next(); + PathBuf::from(c.as_os_str()) + } else { + PathBuf::new() + }; + + for component in components { + match component { + Component::Prefix(..) => unreachable!(), + Component::RootDir => { + ret.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + ret.pop(); + } + Component::Normal(c) => { + ret.push(c); + } + } + } + ret +} diff --git a/crates/util/src/rel_path.rs b/crates/path/src/rel_path.rs similarity index 62% rename from crates/util/src/rel_path.rs rename to crates/path/src/rel_path.rs index 382a4bad37b835..80d1d96f338738 100644 --- a/crates/util/src/rel_path.rs +++ b/crates/path/src/rel_path.rs @@ -1,6 +1,4 @@ -use crate::paths::{PathStyle, is_absolute}; use anyhow::{Context as _, Result, anyhow}; -use serde::{Deserialize, Serialize}; use std::{ borrow::{Borrow, Cow}, fmt, @@ -9,6 +7,12 @@ use std::{ sync::Arc, }; +use crate::{ + PathStyle, + abs_path::{AbsPath, AbsPathBuf}, + is_absolute, +}; + /// A file system path that is guaranteed to be relative and normalized. /// /// This type can be used to represent paths in a uniform way, regardless of @@ -20,20 +24,22 @@ use std::{ /// /// Relative paths are also guaranteed to be valid unicode. #[repr(transparent)] -#[derive(PartialEq, Eq, Hash, Serialize)] +#[derive(PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct RelPath(str); /// An owned representation of a file system path that is guaranteed to be /// relative and normalized. /// /// This type is to [`RelPath`] as [`std::path::PathBuf`] is to [`std::path::Path`] -#[derive(PartialEq, Eq, Clone, Ord, PartialOrd, Serialize)] +#[derive(PartialEq, Eq, Clone, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct RelPathBuf(String); impl RelPath { /// Creates an empty [`RelPath`]. pub fn empty() -> &'static Self { - Self::new_unchecked("") + Self::from_str("") } /// Creates an empty [`RelPath`]. @@ -54,7 +60,7 @@ impl RelPath { let mut path = path.to_str().context("non utf-8 path")?; let (prefixes, suffixes): (&[_], &[_]) = match path_style { - PathStyle::Posix => (&["./"], &['/']), + PathStyle::Unix => (&["./"], &['/']), PathStyle::Windows => (&["./", ".\\"], &['/', '\\']), }; @@ -77,7 +83,7 @@ impl RelPath { } let mut result = match string { - Cow::Borrowed(string) => Cow::Borrowed(Self::new_unchecked(string)), + Cow::Borrowed(string) => Cow::Borrowed(Self::from_str(string)), Cow::Owned(string) => Cow::Owned(RelPathBuf(string)), }; @@ -95,7 +101,7 @@ impl RelPath { return Err(anyhow!("path is not relative: {result:?}")); } } - other => normalized.push(RelPath::new_unchecked(other)), + other => normalized.push(RelPath::from_str(other)), } } result = Cow::Owned(normalized) @@ -104,20 +110,25 @@ impl RelPath { Ok(result) } + #[track_caller] + pub fn new_test<'a>(path: &'a str) -> Cow<'a, Self> { + Self::new(Path::new(path), PathStyle::Unix).unwrap() + } + /// Converts a path that is already normalized and uses '/' separators /// into a [`RelPath`] . /// /// Returns an error if the path is not already in the correct format. #[track_caller] - pub fn unix + ?Sized>(path: &S) -> anyhow::Result<&Self> { + pub fn from_unix_str + ?Sized>(path: &S) -> anyhow::Result<&Self> { let path = path.as_ref(); - match Self::new(path, PathStyle::Posix)? { + match Self::new(path, PathStyle::Unix)? { Cow::Borrowed(path) => Ok(path), Cow::Owned(_) => Err(anyhow!("invalid relative path {path:?}")), } } - fn new_unchecked(s: &str) -> &Self { + fn from_str(s: &str) -> &Self { // Safety: `RelPath` is a transparent wrapper around `str`. unsafe { &*(s as *const str as *const Self) } } @@ -131,7 +142,12 @@ impl RelPath { } pub fn ancestors(&self) -> RelPathAncestors<'_> { - RelPathAncestors(Some(&self.0)) + RelPathAncestors { + full: &self.0, + front: self.0.len(), + back: 0, + done: false, + } } pub fn file_name(&self) -> Option<&str> { @@ -156,7 +172,22 @@ impl RelPath { self.strip_prefix(other).is_ok() } + /// Returns true if this path is a strict descendant of `ancestor`. + /// + /// Unlike `starts_with`, this returns false when `self == ancestor` + /// and false when `ancestor` is empty (since every path trivially + /// starts with the empty prefix). + pub fn is_descendant_of(&self, ancestor: &Self) -> bool { + if ancestor.is_empty() || self == ancestor { + return false; + } + self.starts_with(ancestor) + } + pub fn ends_with(&self, other: &Self) -> bool { + if other.is_empty() { + return true; + } if let Some(suffix) = self.0.strip_suffix(&other.0) { if suffix.ends_with('/') { return true; @@ -173,7 +204,7 @@ impl RelPath { } if let Some(suffix) = self.0.strip_prefix(&other.0) { if let Some(suffix) = suffix.strip_prefix('/') { - return Ok(Self::new_unchecked(suffix)); + return Ok(Self::from_str(suffix)); } else if suffix.is_empty() { return Ok(Self::empty()); } @@ -182,7 +213,11 @@ impl RelPath { } pub fn len(&self) -> usize { - self.0.matches('/').count() + 1 + if self.0.is_empty() { + 0 + } else { + self.0.matches('/').count() + 1 + } } pub fn last_n_components(&self, count: usize) -> Option<&Self> { @@ -198,15 +233,14 @@ impl RelPath { } } - pub fn join(&self, other: &Self) -> Arc { - let result = if self.0.is_empty() { - Cow::Borrowed(&other.0) + pub fn join(&self, other: &Self) -> RelPathBuf { + if self.0.is_empty() { + other.to_rel_path_buf() } else if other.0.is_empty() { - Cow::Borrowed(&self.0) + self.to_rel_path_buf() } else { - Cow::Owned(format!("{}/{}", &self.0, &other.0)) - }; - Arc::from(Self::new_unchecked(result.as_ref())) + RelPathBuf(format!("{}/{}", &self.0, &other.0)) + } } pub fn to_rel_path_buf(&self) -> RelPathBuf { @@ -217,23 +251,13 @@ impl RelPath { Arc::from(self) } - /// Convert the path into the wire representation. - pub fn to_proto(&self) -> String { - self.as_unix_str().to_owned() - } - - /// Load the path from its wire representation. - pub fn from_proto(path: &str) -> Result> { - Ok(Arc::from(Self::unix(path)?)) - } - /// Convert the path into a string with the given path style. /// /// Whenever a path is presented to the user, it should be converted to /// a string via this method. pub fn display(&self, style: PathStyle) -> Cow<'_, str> { match style { - PathStyle::Posix => Cow::Borrowed(&self.0), + PathStyle::Unix => Cow::Borrowed(&self.0), PathStyle::Windows if self.0.contains('/') => Cow::Owned(self.0.replace('/', "\\")), PathStyle::Windows => Cow::Borrowed(&self.0), } @@ -255,18 +279,15 @@ impl RelPath { pub fn as_std_path(&self) -> &Path { Path::new(&self.0) } -} - -#[derive(Debug)] -pub struct StripPrefixError; -impl std::fmt::Display for StripPrefixError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("prefix not found") + /// Resolves this relative path against an absolute base path. + pub fn absolutize(&self, base: impl AsRef) -> AbsPathBuf { + base.as_ref().join(self.as_unix_str()) } } -impl std::error::Error for StripPrefixError {} +#[derive(Debug)] +pub struct StripPrefixError; impl ToOwned for RelPath { type Owned = RelPathBuf; @@ -324,14 +345,33 @@ impl RelPathBuf { } pub fn push(&mut self, path: &RelPath) { + if path.is_empty() { + return; + } if !self.is_empty() { self.0.push('/'); } self.0.push_str(&path.0); } + pub fn push_component(&mut self, component: &str) -> Result<()> { + anyhow::ensure!( + !component.is_empty() + && !component.contains('/') + && component != "." + && component != "..", + "invalid relative path component: {component:?}" + ); + + if !self.is_empty() { + self.0.push('/'); + } + self.0.push_str(component); + Ok(()) + } + pub fn as_rel_path(&self) -> &RelPath { - RelPath::new_unchecked(self.0.as_str()) + RelPath::from_str(self.0.as_str()) } pub fn set_extension(&mut self, extension: &str) -> bool { @@ -339,7 +379,7 @@ impl RelPathBuf { let mut filename = PathBuf::from(filename); filename.set_extension(extension); self.pop(); - self.0.push_str(filename.to_str().unwrap()); + self.push(RelPath::from_str(filename.to_str().unwrap())); true } else { false @@ -347,33 +387,21 @@ impl RelPathBuf { } } -impl<'de> Deserialize<'de> for RelPathBuf { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - let path = String::deserialize(deserializer)?; - let rel_path = - RelPath::new(Path::new(&path), PathStyle::local()).map_err(serde::de::Error::custom)?; - Ok(rel_path.into_owned()) - } -} - -impl Into> for RelPathBuf { - fn into(self) -> Arc { - Arc::from(self.as_rel_path()) +impl PartialOrd for RelPathBuf { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) } } -impl AsRef for RelPathBuf { - fn as_ref(&self) -> &Path { - self.as_std_path() +impl Ord for RelPathBuf { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.as_rel_path().cmp(other.as_rel_path()) } } -impl AsRef for RelPath { - fn as_ref(&self) -> &Path { - self.as_std_path() +impl From for Arc { + fn from(value: RelPathBuf) -> Self { + Arc::from(value.as_rel_path()) } } @@ -383,12 +411,24 @@ impl AsRef for RelPathBuf { } } +impl AsRef for RelPathBuf { + fn as_ref(&self) -> &Path { + self.as_std_path() + } +} + impl AsRef for RelPath { fn as_ref(&self) -> &RelPath { self } } +impl AsRef for RelPath { + fn as_ref(&self) -> &Path { + self.as_std_path() + } +} + impl Deref for RelPathBuf { type Target = RelPath; @@ -406,20 +446,37 @@ impl<'a> From<&'a RelPath> for Cow<'a, RelPath> { impl From<&RelPath> for Arc { fn from(rel_path: &RelPath) -> Self { let bytes: Arc = Arc::from(&rel_path.0); + // SAFETY: `AbsPath` is a `repr(transparent)` wrapper around `Path`. unsafe { Arc::from_raw(Arc::into_raw(bytes) as *const RelPath) } } } +impl<'a> TryFrom<&'a str> for &'a RelPath { + type Error = anyhow::Error; + + fn try_from(s: &'a str) -> Result { + RelPath::from_unix_str(s) + } +} + +impl TryFrom<&str> for RelPathBuf { + type Error = anyhow::Error; + + fn try_from(s: &str) -> Result { + RelPath::new(Path::new(s), PathStyle::Unix).map(|cow| cow.into_owned()) + } +} + #[cfg(any(test, feature = "test-support"))] #[track_caller] pub fn rel_path(path: &str) -> &RelPath { - RelPath::unix(path).unwrap() + RelPath::from_unix_str(path).unwrap() } #[cfg(any(test, feature = "test-support"))] #[track_caller] pub fn rel_path_buf(path: &str) -> RelPathBuf { - RelPath::unix(path).unwrap().to_rel_path_buf() + rel_path(path).to_owned() } impl PartialEq for RelPath { @@ -428,26 +485,45 @@ impl PartialEq for RelPath { } } -pub trait PathExt { - fn to_rel_path_buf(&self) -> Result; +impl PartialEq for RelPathBuf { + fn eq(&self, other: &RelPath) -> bool { + self.as_rel_path() == other + } +} + +impl PartialEq for RelPath { + fn eq(&self, other: &RelPathBuf) -> bool { + other.as_rel_path() == self + } +} + +impl fmt::Display for RelPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } } -impl + ?Sized> PathExt for T { - fn to_rel_path_buf(&self) -> Result { - Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned()) +impl fmt::Display for RelPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) } } #[derive(Default)] pub struct RelPathComponents<'a>(&'a str); -pub struct RelPathAncestors<'a>(Option<&'a str>); +pub struct RelPathAncestors<'a> { + full: &'a str, + front: usize, + back: usize, + done: bool, +} const SEPARATOR: char = '/'; impl<'a> RelPathComponents<'a> { pub fn rest(&self) -> &'a RelPath { - RelPath::new_unchecked(self.0) + RelPath::from_str(self.0) } } @@ -473,15 +549,35 @@ impl<'a> Iterator for RelPathAncestors<'a> { type Item = &'a RelPath; fn next(&mut self) -> Option { - let result = self.0?; - if let Some(sep_ix) = result.rfind(SEPARATOR) { - self.0 = Some(&result[..sep_ix]); - } else if !result.is_empty() { - self.0 = Some(""); + if self.done { + return None; + } + let result = &self.full[..self.front]; + if self.front == self.back { + self.done = true; + } else { + self.front = result.rfind(SEPARATOR).unwrap_or_default(); + } + Some(RelPath::from_str(result)) + } +} + +impl<'a> DoubleEndedIterator for RelPathAncestors<'a> { + fn next_back(&mut self) -> Option { + if self.done { + return None; + } + let result = &self.full[..self.back]; + if self.front == self.back { + self.done = true; } else { - self.0 = None; + let search_start = if self.back == 0 { 0 } else { self.back + 1 }; + self.back = match self.full[search_start..].find(SEPARATOR) { + Some(sep_ix) => search_start + sep_ix, + None => self.full.len(), + }; } - Some(RelPath::new_unchecked(result)) + Some(RelPath::from_str(result)) } } @@ -501,11 +597,22 @@ impl<'a> DoubleEndedIterator for RelPathComponents<'a> { } } +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RelPathBuf { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let path = String::deserialize(deserializer)?; + let rel_path = + RelPath::new(Path::new(&path), PathStyle::local()).map_err(serde::de::Error::custom)?; + Ok(rel_path.into_owned()) + } +} + #[cfg(test)] mod tests { use super::*; - use itertools::Itertools; - use pretty_assertions::assert_matches; #[test] fn test_rel_path_new() { @@ -515,11 +622,11 @@ mod tests { let path = RelPath::new("foo/".as_ref(), PathStyle::local()).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new("foo\\".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); assert_eq!( RelPath::new("foo/bar/../baz/./quux/".as_ref(), PathStyle::local()) @@ -528,29 +635,29 @@ mod tests { rel_path("foo/baz/quux") ); - let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Posix).unwrap(); + let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Unix).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new(".\\foo".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new("./.\\./foo/\\/".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path, rel_path("foo").into()); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); - let path = RelPath::new("foo/./bar".as_ref(), PathStyle::Posix).unwrap(); + let path = RelPath::new("foo/./bar".as_ref(), PathStyle::Unix).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Owned(_)); + assert!(matches!(path, Cow::Owned(_))); let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Borrowed(_)); + assert!(matches!(path, Cow::Borrowed(_))); let path = RelPath::new(".\\foo\\bar".as_ref(), PathStyle::Windows).unwrap(); assert_eq!(path.as_ref(), rel_path("foo/bar")); - assert_matches!(path, Cow::Owned(_)); + assert!(matches!(path, Cow::Owned(_))); } #[test] @@ -590,6 +697,41 @@ mod tests { let mut ancestors = path.ancestors(); assert_eq!(ancestors.next(), Some(RelPath::empty())); assert_eq!(ancestors.next(), None); + + let path = rel_path("foo/bar/baz"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(rel_path(""))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo"))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo/bar"))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo/bar/baz"))); + assert_eq!(ancestors.next_back(), None); + + let path = rel_path("foo/bar/baz"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next(), Some(rel_path("foo/bar/baz"))); + assert_eq!(ancestors.next_back(), Some(rel_path(""))); + assert_eq!(ancestors.next(), Some(rel_path("foo/bar"))); + assert_eq!(ancestors.next_back(), Some(rel_path("foo"))); + assert_eq!(ancestors.next(), None); + assert_eq!(ancestors.next_back(), None); + + let path = rel_path("foo"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(RelPath::empty())); + assert_eq!(ancestors.next_back(), Some(rel_path("foo"))); + assert_eq!(ancestors.next_back(), None); + + let path = RelPath::empty(); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(RelPath::empty())); + assert_eq!(ancestors.next_back(), None); + + let path = rel_path("über/x"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next_back(), Some(RelPath::empty())); + assert_eq!(ancestors.next_back(), Some(rel_path("über"))); + assert_eq!(ancestors.next_back(), Some(rel_path("über/x"))); + assert_eq!(ancestors.next_back(), None); } #[test] @@ -602,13 +744,18 @@ mod tests { #[test] fn test_rel_path_partial_ord_is_compatible_with_std() { let test_cases = ["a/b/c", "relative/path/with/dot.", "relative/path/with.dot"]; - for [lhs, rhs] in test_cases.iter().array_combinations::<2>() { - assert_eq!( - Path::new(lhs).cmp(Path::new(rhs)), - RelPath::unix(lhs) - .unwrap() - .cmp(&RelPath::unix(rhs).unwrap()) - ); + for (i, lhs) in test_cases.iter().enumerate() { + for rhs in &test_cases[i + 1..] { + assert_eq!( + Path::new(lhs).cmp(Path::new(rhs)), + RelPath::from_unix_str(lhs) + .unwrap() + .cmp(RelPath::from_unix_str(rhs).unwrap()), + "ordering mismatch for {:?} vs {:?}", + lhs, + rhs, + ); + } } } @@ -621,19 +768,28 @@ mod tests { assert_eq!(child.strip_prefix(parent).unwrap(), child); } + #[test] + fn test_ends_with() { + assert!(rel_path("foo/bar").ends_with(rel_path("bar"))); + assert!(rel_path("foo/bar").ends_with(rel_path("foo/bar"))); + assert!(rel_path("foo/bar").ends_with(RelPath::empty())); + assert!(RelPath::empty().ends_with(RelPath::empty())); + assert!(!rel_path("foobar").ends_with(rel_path("bar"))); + } + #[test] fn test_rel_path_constructors_absolute_path() { assert!(RelPath::new(Path::new("/a/b"), PathStyle::Windows).is_err()); assert!(RelPath::new(Path::new("\\a\\b"), PathStyle::Windows).is_err()); - assert!(RelPath::new(Path::new("/a/b"), PathStyle::Posix).is_err()); + assert!(RelPath::new(Path::new("/a/b"), PathStyle::Unix).is_err()); assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Windows).is_err()); assert!(RelPath::new(Path::new("C:\\a\\b"), PathStyle::Windows).is_err()); - assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Posix).is_ok()); + assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Unix).is_ok()); } #[test] fn test_pop() { - let mut path = rel_path("a/b").to_rel_path_buf(); + let mut path = rel_path_buf("a/b"); path.pop(); assert_eq!(path.as_rel_path().as_unix_str(), "a"); path.pop(); @@ -641,4 +797,27 @@ mod tests { path.pop(); assert_eq!(path.as_rel_path().as_unix_str(), ""); } + + #[test] + fn test_len() { + assert_eq!(RelPath::empty().len(), 0); + assert_eq!(rel_path("a").len(), 1); + assert_eq!(rel_path("a/b").len(), 2); + assert_eq!(rel_path("a/b/c").len(), 3); + } + + #[test] + fn test_set_extension() { + let mut path = rel_path_buf("a/b/c.txt"); + assert!(path.set_extension("rs")); + assert_eq!(path.as_rel_path().as_unix_str(), "a/b/c.rs"); + + let mut single = rel_path_buf("file.txt"); + assert!(single.set_extension("md")); + assert_eq!(single.as_rel_path().as_unix_str(), "file.md"); + + let mut no_ext = rel_path_buf("a/b/c"); + assert!(no_ext.set_extension("rs")); + assert_eq!(no_ext.as_rel_path().as_unix_str(), "a/b/c.rs"); + } } diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 30eea0d94815db..08f7c3ec229ff8 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -68,7 +68,7 @@ static CONFIG_DIR: OnceLock = OnceLock::new(); /// Returns the relative path to the zed_server directory on the ssh host. pub fn remote_server_dir_relative() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed_server").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed_server").unwrap()); *CACHED } @@ -76,7 +76,7 @@ pub fn remote_server_dir_relative() -> &'static RelPath { /// Returns the relative path to the zed_wsl_server directory on the wsl host. pub fn remote_wsl_server_dir_relative() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed_wsl_server").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed_wsl_server").unwrap()); *CACHED } @@ -496,21 +496,21 @@ pub fn local_vscode_folder_name() -> &'static str { /// Returns the relative path to a `settings.json` file within a project. pub fn local_settings_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed/settings.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed/settings.json").unwrap()); *CACHED } /// Returns the relative path to a `tasks.json` file within a project. pub fn local_tasks_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed/tasks.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed/tasks.json").unwrap()); *CACHED } /// Returns the relative path to a `.vscode/tasks.json` file within a project. pub fn local_vscode_tasks_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".vscode/tasks.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".vscode/tasks.json").unwrap()); *CACHED } @@ -526,14 +526,14 @@ pub fn task_file_name() -> &'static str { /// .zed/debug.json pub fn local_debug_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".zed/debug.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".zed/debug.json").unwrap()); *CACHED } /// Returns the relative path to a `.vscode/launch.json` file within a project. pub fn local_vscode_launch_file_relative_path() -> &'static RelPath { static CACHED: LazyLock<&'static RelPath> = - LazyLock::new(|| RelPath::unix(".vscode/launch.json").unwrap()); + LazyLock::new(|| RelPath::from_unix_str(".vscode/launch.json").unwrap()); *CACHED } diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs index fc90c5f2530c57..7e94e04ecfc0cc 100644 --- a/crates/picker/src/footer.rs +++ b/crates/picker/src/footer.rs @@ -98,6 +98,7 @@ impl Picker { cx: &mut Context, ) -> Option { let actions = self.delegate.actions_menu(window, cx); + if self.preview.is_none() && actions.is_empty() { return None; } @@ -111,9 +112,9 @@ impl Picker { .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant) - .child(div().when(self.preview.is_some(), |this| { + .when(self.preview.is_some(), |this| { this.child(self.render_preview_controls(window, cx)) - })) + }) .when(!actions.is_empty(), |this| { this.child(self.render_actions_button(actions.into(), focus_handle, window, cx)) }) @@ -123,7 +124,7 @@ impl Picker { fn render_preview_controls( &self, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -132,6 +133,12 @@ impl Picker { let current = self.preview_layout().unwrap_or(preview::Layout::Hidden); let preview_visible = current != preview::Layout::Hidden; + let diff_split = if self.is_auto_vertical(window) { + IconName::DiffSplitAuto + } else { + IconName::DiffSplit + }; + h_flex() .child( Button::new("picker-preview-toggle", "Preview") @@ -145,37 +152,37 @@ impl Picker { ), ) .when(preview_visible, |this| { - this.child(Divider::vertical().h_4().mx_1()) + this.child(Divider::vertical().mx_1()) .child( - IconButton::new("picker-preview-right", IconName::DiffSplit) + IconButton::new("picker-preview-below", IconName::DiffUnified) .icon_size(IconSize::Small) - .toggle_state(current == preview::Layout::Right) + .toggle_state(current == preview::Layout::Below) .tooltip(move |_window, cx| { Tooltip::for_action_in( - "Preview to the Right", - &SetPreviewRight, - &right_focus_handle, + "Preview Below", + &SetPreviewBelow, + &below_focus_handle, cx, ) }) .on_click(cx.listener(|this, _, window, cx| { - this.set_preview_layout(preview::Layout::Right, window, cx) + this.set_preview_layout(preview::Layout::Below, window, cx) })), ) .child( - IconButton::new("picker-preview-below", IconName::DiffUnified) + IconButton::new("picker-preview-right", diff_split) .icon_size(IconSize::Small) - .toggle_state(current == preview::Layout::Below) + .toggle_state(current == preview::Layout::Right) .tooltip(move |_window, cx| { Tooltip::for_action_in( - "Preview Below", - &SetPreviewBelow, - &below_focus_handle, + "Preview to the Right", + &SetPreviewRight, + &right_focus_handle, cx, ) }) .on_click(cx.listener(|this, _, window, cx| { - this.set_preview_layout(preview::Layout::Below, window, cx) + this.set_preview_layout(preview::Layout::Right, window, cx) })), ) }) @@ -188,11 +195,8 @@ impl Picker { _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let _ = cx; PopoverMenu::new("picker-actions-menu") .with_handle(self.actions_menu_handle.clone()) - .attach(gpui::Anchor::TopRight) - .anchor(gpui::Anchor::BottomRight) .trigger( Button::new("picker-actions-trigger", "Actions…") .key_binding( @@ -212,5 +216,11 @@ impl Picker { menu })) }) + .attach(gpui::Anchor::TopRight) + .anchor(gpui::Anchor::BottomRight) + .offset(gpui::Point { + x: px(0.0), + y: px(-2.0), + }) } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 2f9d0ad3c4f48d..9f8baac0cbad98 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -11,7 +11,10 @@ use serde::Deserialize; use std::{ cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, }; -use ui::{ContextMenu, Divider, DocumentationAside, PopoverMenuHandle, prelude::*, v_flex}; +use ui::{ + Checkbox, ContextMenu, Divider, DocumentationAside, KeyBinding, PopoverMenuHandle, Tooltip, + prelude::*, +}; use ui_input::ErasedEditorEvent; use util::ResultExt; use workspace::ModalView; @@ -73,8 +76,13 @@ actions!( SetPreviewHidden, /// Opens the footer's actions menu. ToggleActionsMenu, - /// Take the picker's content and open it in a multibuffer - ToMultiBuffer, + /// Toggles multi-select mode, in which clicking items adds them to + /// the selection instead of opening them + ToggleMultiSelect, + /// Toggles the current item in the multi-selection and advances to + /// the next item, starting multi-select mode if it isn't already + /// active + MultiSelectNext, ] ); @@ -123,6 +131,7 @@ pub struct Picker { preview: Option, pending_update_matches: Option, confirm_on_update: Option, + select_instead_of_open: bool, shape: shape::Shape, /// The size the picker opens at (and resets to). Defaults depend on whether /// the picker has a preview; see [`Picker::initial_width`] / [`Picker::max_height`]. @@ -236,6 +245,41 @@ pub trait PickerDelegate: Sized + 'static { None } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>); + /// Whether this delegate supports selecting multiple items at once. When + /// `true`, the delegate owns the multi-selection state; the picker only + /// drives the generic UX (toggle action, indicator, click routing). + fn supports_multi_select(&self) -> bool { + false + } + /// Whether the item at `ix` is part of the current multi-selection. + fn is_item_selected(&self, _ix: usize) -> bool { + false + } + /// Toggle whether the item at `ix` is part of the multi-selection. Items + /// that cannot participate in multi-selection (e.g. non-file entries) + /// should leave the selection unchanged. + fn toggle_item_selected( + &mut self, + _ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + } + /// Number of items currently in the multi-selection. + fn selected_item_count(&self) -> usize { + 0 + } + /// Clear the multi-selection. + fn clear_selection(&mut self, _cx: &mut Context>) {} + /// Open every item in the multi-selection. Called on confirm when the + /// selection is non-empty; implementations should clear the selection. + fn confirm_multi( + &mut self, + _secondary: bool, + _window: &mut Window, + _cx: &mut Context>, + ) { + } /// Instead of interacting with currently selected entry, treats editor input literally, /// performing some kind of action on it. fn confirm_input( @@ -280,30 +324,17 @@ pub trait PickerDelegate: Sized + 'static { None } + /// Overrides the search bar entirely. Most delegates should return `None` + /// to get the picker-rendered default (which includes + /// [`Self::searchbar_trailer`] and the multi-select toggle); override for + /// full control over the search bar. fn render_editor( &self, - editor: &Arc, - window: &mut Window, - cx: &mut Context>, - ) -> Div { - v_flex() - .when( - self.editor_position() == PickerEditorPosition::End, - |this| this.child(Divider::horizontal()), - ) - .child( - h_flex() - .overflow_hidden() - .flex_none() - .h_9() - .px_2p5() - .child(div().flex_1().child(editor.render(window, cx))) - .children(self.searchbar_trailer(window, cx)), - ) - .when( - self.editor_position() == PickerEditorPosition::Start, - |this| this.child(Divider::horizontal()), - ) + _editor: &Arc, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option
{ + None } fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { @@ -322,6 +353,17 @@ pub trait PickerDelegate: Sized + 'static { cx: &mut Context>, ) -> Option; + fn render_match_with_checkbox( + &self, + _ix: usize, + _selected: bool, + _checkbox: AnyElement, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + None + } + fn render_header( &self, _window: &mut Window, @@ -528,6 +570,7 @@ impl Picker { element_container, pending_update_matches: None, confirm_on_update: None, + select_instead_of_open: false, preview, shape_loaded_from_persistence: persisted_shape.is_some(), shape: persisted_shape.unwrap_or_else(|| { @@ -547,6 +590,9 @@ impl Picker { actions_menu_handle: PopoverMenuHandle::default(), reopenable: true, }; + // give delegate the initial preview layout + this.delegate + .preview_layout_changed(matches!(initial_layout, preview::Layout::Right)); if this.reopenable { let focus_handle = this.focus_handle(cx); workspace::register_reopenable_picker(&focus_handle, cx); @@ -861,11 +907,49 @@ impl Picker { pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { if self.delegate.should_dismiss() { + self.select_instead_of_open = false; + self.delegate.clear_selection(cx); self.delegate.dismissed(window, cx); cx.emit(DismissEvent); } } + fn toggle_multi_select( + &mut self, + _: &ToggleMultiSelect, + _window: &mut Window, + cx: &mut Context, + ) { + if !self.delegate.supports_multi_select() { + cx.propagate(); + return; + } + self.select_instead_of_open = !self.select_instead_of_open; + if !self.select_instead_of_open { + self.delegate.clear_selection(cx); + } + cx.notify(); + } + + fn multi_select_next( + &mut self, + _: &MultiSelectNext, + window: &mut Window, + cx: &mut Context, + ) { + // Propagate so `tab` retains its other meanings (e.g. + // `ConfirmCompletion`) in pickers without multi-select. + if !self.delegate.supports_multi_select() { + cx.propagate(); + return; + } + self.select_instead_of_open = true; + let ix = self.delegate.selected_index(); + self.delegate.toggle_item_selected(ix, window, cx); + self.select_next(&menu::SelectNext, window, cx); + cx.notify(); + } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { if self.pending_update_matches.is_some() && !self.delegate.finalize_update_matches( @@ -968,11 +1052,20 @@ impl Picker { return; } self.set_selected_index(ix, None, false, window, cx); - self.do_confirm(secondary, window, cx) + if self.delegate.supports_multi_select() && (secondary || self.select_instead_of_open) { + self.select_instead_of_open = true; + self.delegate.toggle_item_selected(ix, window, cx); + cx.notify(); + } else { + self.do_confirm(secondary, window, cx); + } } fn do_confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context) { - if let Some(update_query) = self.delegate.confirm_update_query(window, cx) { + if self.delegate.supports_multi_select() && self.delegate.selected_item_count() > 0 { + self.select_instead_of_open = false; + self.delegate.confirm_multi(secondary, window, cx); + } else if let Some(update_query) = self.delegate.confirm_update_query(window, cx) { self.set_query(&update_query, window, cx); self.set_selected_index(0, Some(Direction::Down), false, window, cx); } else { @@ -1166,9 +1259,59 @@ impl Picker { let selectable = ix < self.delegate.match_count() && self.delegate.can_select(ix, window, cx); + let supports_multi_select = self.delegate.supports_multi_select(); + let is_multi_selected = supports_multi_select && self.delegate.is_item_selected(ix); + let multi_select_active = supports_multi_select && self.select_instead_of_open; + + let item_with_checkbox = if multi_select_active && selectable { + let checkbox = self + .render_multi_select_indicator(ix, is_multi_selected, cx) + .into_any_element(); + self.delegate.render_match_with_checkbox( + ix, + ix == self.delegate.selected_index(), + checkbox, + window, + cx, + ) + } else { + None + }; + + let use_fallback_indicator = + multi_select_active && selectable && item_with_checkbox.is_none(); + let focus_handle = self.focus_handle(cx); + div() .id(("item", ix)) .when(selectable, |this| this.cursor_pointer()) + .when(use_fallback_indicator, |this| { + this.hover(|s| s.bg(cx.theme().colors().ghost_element_hover)) + }) + .when(multi_select_active && selectable, |this| { + this.tooltip(Tooltip::element(move |_window, cx| { + h_flex() + .gap_2() + .child( + h_flex() + .gap_1() + .child(KeyBinding::for_action_in( + &MultiSelectNext, + &focus_handle, + cx, + )) + .child(Label::new("Select")), + ) + .child(Divider::vertical()) + .child( + h_flex() + .gap_1() + .child(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)) + .child(Label::new("Open")), + ) + .into_any_element() + })) + }) .child( canvas( move |bounds, _window, _cx| { @@ -1209,12 +1352,37 @@ impl Picker { } })) }) - .children(self.delegate.render_match( - ix, - ix == self.delegate.selected_index(), - window, - cx, - )) + .map(|row| { + if let Some(item) = item_with_checkbox { + row.child(item) + } else if supports_multi_select { + row.child( + h_flex() + // Headers and separators cannot be part of the + // selection, so they get no indicator. + .when(use_fallback_indicator, |this| { + this.child(self.render_multi_select_indicator( + ix, + is_multi_selected, + cx, + )) + }) + .children(self.delegate.render_match( + ix, + ix == self.delegate.selected_index(), + window, + cx, + )), + ) + } else { + row.children(self.delegate.render_match( + ix, + ix == self.delegate.selected_index(), + window, + cx, + )) + } + }) .when( self.delegate.separators_after_indices().contains(&ix), |picker| { @@ -1226,6 +1394,23 @@ impl Picker { ) } + fn render_multi_select_indicator( + &self, + ix: usize, + is_selected: bool, + cx: &mut Context, + ) -> impl IntoElement { + Checkbox::new(("picker-multi-select-checkbox", ix), is_selected.into()) + .fill() + .elevation(ui::ElevationIndex::ModalSurface) + .on_click(cx.listener(move |this, _: &ui::ToggleState, window, cx| { + // Don't let the row's click handler also toggle the item. + cx.stop_propagation(); + this.delegate.toggle_item_selected(ix, window, cx); + cx.notify(); + })) + } + fn render_element_container(&self, cx: &mut Context) -> impl IntoElement { // When the picker shrinks to fit its content, the list infers its size // from its items. When it fills its full height (preview visible), the @@ -1277,10 +1462,34 @@ impl Picker { fn preview_layout(&self) -> Option { self.preview.as_ref().map(|p| p.layout) } + fn is_auto_vertical(&self, window: &Window) -> bool { + self.preview_layout() == Some(preview::Layout::Right) + && self + .size_bounds + .would_clamp_width_if_horizontal(&self.shape, window) + } + /// To check whether we're rendering vertically instead of + /// horizontally due to the auto override + fn preview_layout_rendered(&self, window: &Window) -> Option { + let would_clamp = matches!( + self.preview, + Some(Preview { + layout: preview::Layout::Right, + .. + }) + ) && self.is_auto_vertical(window); + if would_clamp { + Some(preview::Layout::Below) + } else { + self.preview_layout() + } + } #[cfg(any(test, feature = "test-support"))] pub fn results_width(&self, window: &Window) -> gpui::Pixels { - let layout = self.preview_layout().unwrap_or(preview::Layout::Hidden); + let layout = self + .preview_layout_rendered(window) + .unwrap_or(preview::Layout::Hidden); let pos = self .shape .results_position_and_size(layout, &self.size_bounds, window); @@ -1338,6 +1547,9 @@ mod tests { items: Vec, selected_index: usize, confirmed_index: Rc>>, + supports_multi_select: bool, + selected_items: Vec, + multi_confirmed: Rc>>>, } impl TestDelegate { @@ -1346,8 +1558,16 @@ mod tests { items, selected_index: 0, confirmed_index: Rc::new(Cell::new(None)), + supports_multi_select: false, + selected_items: Vec::new(), + multi_confirmed: Rc::new(Cell::new(None)), } } + + fn with_multi_select(mut self) -> Self { + self.supports_multi_select = true; + self + } } impl PickerDelegate for TestDelegate { @@ -1405,6 +1625,45 @@ mod tests { self.confirmed_index.set(Some(self.selected_index)); } + fn supports_multi_select(&self) -> bool { + self.supports_multi_select + } + + fn is_item_selected(&self, ix: usize) -> bool { + self.selected_items.contains(&ix) + } + + fn toggle_item_selected( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + if let Some(position) = self.selected_items.iter().position(|&item| item == ix) { + self.selected_items.remove(position); + } else { + self.selected_items.push(ix); + } + } + + fn selected_item_count(&self) -> usize { + self.selected_items.len() + } + + fn clear_selection(&mut self, _cx: &mut Context>) { + self.selected_items.clear(); + } + + fn confirm_multi( + &mut self, + _secondary: bool, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.multi_confirmed + .set(Some(std::mem::take(&mut self.selected_items))); + } + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} fn render_match( @@ -1499,6 +1758,139 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_multi_select_mode_routes_clicks(cx: &mut TestAppContext) { + init_test(cx); + + let confirmed_index = Rc::new(Cell::new(None)); + let multi_confirmed = Rc::new(Cell::new(None)); + let (picker, cx) = cx.add_window_view(|window, cx| { + let mut delegate = TestDelegate::new(vec![true, true, true]).with_multi_select(); + delegate.confirmed_index = confirmed_index.clone(); + delegate.multi_confirmed = multi_confirmed.clone(); + Picker::uniform_list(delegate, window, cx) + }); + + // A plain click confirms just like in any picker. + picker.update_in(cx, |picker, window, cx| { + picker.handle_click(1, false, window, cx); + }); + assert_eq!(confirmed_index.take(), Some(1)); + picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.selected_item_count(), 0); + }); + + // A secondary (cmd) click starts multi-select mode and toggles the + // clicked item into the selection instead of confirming. + picker.update_in(cx, |picker, window, cx| { + picker.handle_click(2, true, window, cx); + }); + assert_eq!(confirmed_index.take(), None, "cmd+click must not confirm"); + picker.update(cx, |picker, _cx| { + assert!( + picker.select_instead_of_open, + "cmd+click should start multi-select mode" + ); + assert!(picker.delegate.is_item_selected(2)); + }); + + // While the mode is on, plain clicks toggle items too. + picker.update_in(cx, |picker, window, cx| { + picker.handle_click(0, false, window, cx); + }); + assert_eq!( + confirmed_index.take(), + None, + "in-mode clicks must not confirm" + ); + picker.update(cx, |picker, _cx| { + assert!(picker.delegate.is_item_selected(0)); + assert!(picker.delegate.is_item_selected(2)); + assert!(!picker.delegate.is_item_selected(1)); + }); + + // Confirming opens the whole selection and exits the mode. + picker.update_in(cx, |picker, window, cx| { + picker.do_confirm(false, window, cx); + }); + assert_eq!(multi_confirmed.take(), Some(vec![2, 0])); + picker.update_in(cx, |picker, window, cx| { + picker.handle_click(1, false, window, cx); + }); + assert_eq!( + confirmed_index.take(), + Some(1), + "the mode should be off again after confirming" + ); + } + + #[gpui::test] + async fn test_multi_select_next_starts_multi_select_mode(cx: &mut TestAppContext) { + init_test(cx); + + let (picker, cx) = cx.add_window_view(|window, cx| { + Picker::uniform_list( + TestDelegate::new(vec![true, true, true]).with_multi_select(), + window, + cx, + ) + }); + + picker.update_in(cx, |picker, window, cx| { + picker.multi_select_next(&MultiSelectNext, window, cx); + }); + picker.update(cx, |picker, _cx| { + assert!( + picker.select_instead_of_open, + "selecting an item should start multi-select mode" + ); + assert!(picker.delegate.is_item_selected(0)); + assert_eq!( + picker.delegate.selected_index(), + 1, + "selecting should advance the cursor to the next item" + ); + }); + + // In pickers without multi-select the action does nothing. + let (plain_picker, cx) = cx.add_window_view(|window, cx| { + Picker::uniform_list(TestDelegate::new(vec![true, true]), window, cx) + }); + plain_picker.update_in(cx, |picker, window, cx| { + picker.multi_select_next(&MultiSelectNext, window, cx); + }); + plain_picker.update(cx, |picker, _cx| { + assert!(!picker.select_instead_of_open); + assert_eq!(picker.delegate.selected_item_count(), 0); + }); + } + + #[gpui::test] + async fn test_exiting_multi_select_mode_clears_selection(cx: &mut TestAppContext) { + init_test(cx); + + let (picker, cx) = cx.add_window_view(|window, cx| { + Picker::uniform_list( + TestDelegate::new(vec![true, true]).with_multi_select(), + window, + cx, + ) + }); + + picker.update_in(cx, |picker, window, cx| { + picker.toggle_multi_select(&ToggleMultiSelect, window, cx); + picker.handle_click(0, false, window, cx); + picker.toggle_multi_select(&ToggleMultiSelect, window, cx); + }); + picker.update(cx, |picker, _cx| { + assert_eq!( + picker.delegate.selected_item_count(), + 0, + "leaving the mode should clear the selection" + ); + }); + } } impl EventEmitter for Picker {} diff --git a/crates/picker/src/preview.rs b/crates/picker/src/preview.rs index 88a9b6219db1b7..df3e9604f70c3b 100644 --- a/crates/picker/src/preview.rs +++ b/crates/picker/src/preview.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use gpui::{AnyElement, App, Entity, IntoElement, Window}; use language::{Anchor, Buffer, HighlightedText}; +use project::Symbol; /// The editor-agnostic interface a [`Picker`](crate::Picker) uses to drive its /// preview. @@ -67,6 +68,13 @@ pub enum PreviewSource { /// Used by pickers (like the text picker) that already hold the matched /// buffer. Buffer(Entity), + /// The buffer is identified by a project symbol; the preview opens it and + /// highlights the symbol's range. + /// + /// Used by pickers (like the project symbols picker) that only know the + /// matched symbol. The highlight is derived from the symbol once its buffer + /// loads, so callers don't supply a [`MatchLocation`]. + Symbol(Symbol), /// No buffer to show; display this message centered in the preview instead. /// /// Used by pickers that have a selection without a previewable buffer (like @@ -117,4 +125,15 @@ impl Update { match_location: Some(highlight), } } + + /// Preview the buffer for `symbol`, highlighting and scrolling to its range. + /// + /// The buffer is opened and the highlight derived by the preview once the + /// buffer loads. + pub fn from_symbol(symbol: Symbol) -> Self { + Self { + source: PreviewSource::Symbol(symbol), + match_location: None, + } + } } diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs index 4ff7c425622548..6abdf57113917c 100644 --- a/crates/picker/src/render.rs +++ b/crates/picker/src/render.rs @@ -10,19 +10,26 @@ use ui::{ use crate::shape::Shape; use crate::{ - ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview, + ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview, ToggleMultiSelect, head::Head, preview::Layout, render::window_controls::{Bottom, Left, LeftCorner, Middle, Right, RightCorner}, }; use crate::{persistence, preview}; +use gpui::Action as _; +use gpui::Focusable as _; +use std::sync::Arc; +use ui::{Divider, Tooltip, prelude::*}; +use ui_input::ErasedEditor; pub mod window_controls; impl Render for Picker { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.finish_any_completed_resize(window, cx); - + // toggle between BelowForced and Right based on whether it'd clamp if + // horizontal + let rendered_layout = self.preview_layout_rendered(window); let content = match &self.preview { Some( preview @ Preview { @@ -32,6 +39,15 @@ impl Render for Picker { ) => self .render_with_preview_below(preview, window, cx) .into_any_element(), + // render sideways based on bounds + Some( + preview @ Preview { + layout: Layout::Right, + .. + }, + ) if rendered_layout == Some(Layout::Below) => self + .render_with_preview_below(preview, window, cx) + .into_any_element(), Some( preview @ Preview { layout: Layout::Right, @@ -58,7 +74,9 @@ impl Render for Picker { .when(has_preview, |this| this.overflow_hidden()) .child(content); - let layout = self.preview_layout().unwrap_or(Layout::Hidden); + let layout = self + .preview_layout_rendered(window) + .unwrap_or(Layout::Hidden); div() .relative() @@ -75,6 +93,54 @@ impl Render for Picker { } impl Picker { + fn render_editor( + &self, + editor: &Arc, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Div { + if let Some(custom) = self.delegate.render_editor(editor, window, cx) { + return custom; + } + let editor_position = self.delegate.editor_position(); + + v_flex() + .when(editor_position == PickerEditorPosition::End, |this| { + this.child(Divider::horizontal()) + }) + .child( + h_flex() + .h_9() + .px_2p5() + .flex_none() + .overflow_hidden() + .child(div().flex_1().child(editor.render(window, cx))) + .children(self.delegate.searchbar_trailer(window, cx)) + .when(self.delegate.supports_multi_select(), |this| { + this.child(self.render_multi_select_toggle(cx)) + }), + ) + .when(editor_position == PickerEditorPosition::Start, |this| { + this.child(Divider::horizontal()) + }) + } + + /// The multi-select toggle is picker-owned so it can reflect the mode, + /// which delegates don't know about. + fn render_multi_select_toggle(&self, cx: &mut Context) -> impl IntoElement { + let active = self.select_instead_of_open; + let focus_handle = self.focus_handle(cx); + IconButton::new("picker-multi-select-toggle", IconName::FileMultiple) + .icon_size(IconSize::Small) + .toggle_state(active) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Toggle Multi Select", &ToggleMultiSelect, &focus_handle, cx) + }) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action(ToggleMultiSelect.boxed_clone(), cx); + })) + } + pub(crate) fn render_results( &self, window: &mut Window, @@ -101,7 +167,7 @@ impl Picker { .relative() .map(|this| { self.shape.apply_results_size( - self.preview_layout(), + self.preview_layout_rendered(window), &self.size_bounds, self.fill_height(), this, @@ -136,16 +202,13 @@ impl Picker { .on_action(cx.listener(Self::set_preview_below)) .on_action(cx.listener(Self::set_preview_hidden)) .on_action(cx.listener(Self::toggle_actions_menu)) + .on_action(cx.listener(Self::toggle_multi_select)) + .on_action(cx.listener(Self::multi_select_next)) .children(match &self.head { Head::Editor(editor) => { if editor_position == PickerEditorPosition::Start { - Some(h_flex().w_full().child( - div().flex_1().child(self.delegate.render_editor( - &editor.clone(), - window, - cx, - )), - )) + let editor = editor.clone(); + Some(self.render_editor(&editor, window, cx)) } else { None } @@ -205,7 +268,8 @@ impl Picker { .children(match &self.head { Head::Editor(editor) => { if editor_position == PickerEditorPosition::End { - Some(self.delegate.render_editor(&editor.clone(), window, cx)) + let editor = editor.clone(); + Some(self.render_editor(&editor, window, cx)) } else { None } @@ -309,7 +373,11 @@ impl Picker { ), ) .when(self.is_resizable(), |this| { - this.child(self.render_resize(window_controls::Middle(preview.layout), window, cx)) + this.child(self.render_resize( + window_controls::Middle(preview::Layout::Below), + window, + cx, + )) }) } @@ -365,7 +433,8 @@ impl Picker { if let Shape::Resizing(pos) = self.shape && !cx.has_active_drag() { - let centered = Shape::centered_and_relative(pos, self.preview_layout(), window); + let centered = + Shape::centered_and_relative(pos, self.preview_layout_rendered(window), window); persistence::store_shape_for_this_layout( D::name(), self.preview_layout(), diff --git a/crates/picker/src/render/window_controls.rs b/crates/picker/src/render/window_controls.rs index a604a600e1f279..cc7fbf2dc7c04b 100644 --- a/crates/picker/src/render/window_controls.rs +++ b/crates/picker/src/render/window_controls.rs @@ -438,7 +438,7 @@ impl Picker { side.position( this, self.shape.clamped_position_and_size( - self.preview_layout(), + self.preview_layout_rendered(window), &self.size_bounds, window, ), @@ -451,7 +451,7 @@ impl Picker { ResizeDrag::::start_new( self.shape, &self.size_bounds, - self.preview_layout(), + self.preview_layout_rendered(window), window, ), |_, _, _, cx| cx.new(|_| DragPreview), @@ -464,7 +464,7 @@ impl Picker { side.clamp( &mut working, &this.size_bounds, - this.preview_layout(), + this.preview_layout_rendered(window), window, ); this.shape = Shape::Resizing(working); @@ -487,9 +487,11 @@ impl Picker { return; } side.revert_to_default_size(&mut self.shape, &self.default_shape, window); - let pos = - self.shape - .clamped_position_and_size(self.preview_layout(), &self.size_bounds, window); + let pos = self.shape.clamped_position_and_size( + self.preview_layout_rendered(window), + &self.size_bounds, + window, + ); self.shape = Shape::Resizing(pos); cx.notify(); } diff --git a/crates/picker/src/shape.rs b/crates/picker/src/shape.rs index d37e2b0aae5930..034834ef781a74 100644 --- a/crates/picker/src/shape.rs +++ b/crates/picker/src/shape.rs @@ -1,6 +1,6 @@ use gpui::Window; use gpui::{Pixels, Rems, Size}; -use ui::{Div, Styled}; +use ui::{Div, Styled, rems_from_px}; use crate::preview::Layout; @@ -20,6 +20,12 @@ pub(crate) struct PositionAndShape { pub(crate) preview: Pixels, } +impl PositionAndShape { + pub(crate) fn width(&self) -> Pixels { + self.right - self.left + } +} + macro_rules! relative_size { ($name:ident, $accessor:ident) => { /// Size type that is the sum of a relative size to the viewport and a @@ -236,12 +242,12 @@ impl Default for SizeBounds { // over the lower bar so clear another 5 rems there. max_height: (RelativeHeight::FULL - Rems(10.0)) * 0.95, min_results: Size { - width: Rems(15.0), - height: Rems(20.0), + width: rems_from_px(280.), + height: rems_from_px(320.), }, min_preview: Size { - width: Rems(8.0), - height: Rems(6.0), + width: rems_from_px(128.), + height: rems_from_px(96.), }, } } @@ -379,6 +385,16 @@ impl SizeBounds { working.preview = working.preview.clamp(min_preview, max_preview); } + pub(crate) fn would_clamp_width_if_horizontal(&self, shape: &Shape, window: &Window) -> bool { + let min_width = self.min_width(Some(Layout::Right), window); + + let unbounded_width = shape + .picker_position_and_size(Some(Layout::Right), window) + .width(); + + unbounded_width <= min_width + } + /// Clamps a whole picker rect (results + preview) into bounds: the total size /// against the per-layout min/max, then the divider so both panes keep their /// minimums. Width is clamped about its center, height anchored at the top. diff --git a/crates/picker_preview/Cargo.toml b/crates/picker_preview/Cargo.toml index 49417be543557f..12d7aec9cc3692 100644 --- a/crates/picker_preview/Cargo.toml +++ b/crates/picker_preview/Cargo.toml @@ -13,7 +13,7 @@ path = "src/picker_preview.rs" doctest = false [dependencies] -anyhow.workspace = true + editor.workspace = true gpui.workspace = true language.workspace = true diff --git a/crates/picker_preview/src/picker_preview.rs b/crates/picker_preview/src/picker_preview.rs index 50aff9d3ce764e..3870dc3690114c 100644 --- a/crates/picker_preview/src/picker_preview.rs +++ b/crates/picker_preview/src/picker_preview.rs @@ -3,17 +3,16 @@ use std::sync::Arc; use gpui::{ - Action, AnyElement, App, AppContext as _, Context, Entity, IntoElement, Pixels, StyledText, - Task, TaskExt as _, Window, px, + AnyElement, App, AppContext as _, Context, Entity, IntoElement, Pixels, StyledText, Task, + Window, px, }; -use language::{Buffer, HighlightedText, HighlightedTextBuilder, ToPoint}; -use picker::{ - MatchLocation, PreviewBackend, PreviewLayout, PreviewSource, PreviewUpdate, ToMultiBuffer, -}; -use project::Project; +use language::{Bias, Buffer, HighlightedText, HighlightedTextBuilder, ToPoint}; +use picker::{MatchLocation, PreviewBackend, PreviewLayout, PreviewSource, PreviewUpdate}; +use project::{Project, Symbol}; use rope::Point; use settings::Settings; use ui::{ActiveTheme, Color, div, prelude::*, v_flex}; +use util::ResultExt as _; use util::rel_path::RelPath; use editor::{Editor, EditorSettings, RowHighlightOptions, display_map::HighlightKey}; @@ -61,6 +60,8 @@ struct EditorPreview { /// When set show a text message instead of a preview message: Option, preview_editor: Entity, + /// Store the load preview task so we have only one at the time + pending_update: Task<()>, } impl EditorPreview { @@ -100,6 +101,7 @@ impl EditorPreview { preview_editor, current_path: None, message: None, + pending_update: Task::ready(()), }; this.clear(); // picker starts with no results. this @@ -125,6 +127,9 @@ impl EditorPreview { self.update_from_buffer(buffer, highlight, window, cx); cx.notify(); } + PreviewSource::Symbol(symbol) => { + self.update_from_symbol(symbol, window, cx); + } PreviewSource::Message(message) => { self.message = Some(message); cx.notify(); @@ -152,15 +157,41 @@ impl EditorPreview { } }); - cx.spawn_in(window, async move |this, cx| { - let buffer = open_task.await?; + self.pending_update = cx.spawn_in(window, async move |this, cx| { + let Some(buffer) = open_task.await.log_err() else { + return; + }; this.update_in(cx, |this, window, cx| { this.update_from_buffer(buffer, highlight, window, cx); cx.notify(); - })?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + }) + .ok(); + }); + } + + fn update_from_symbol(&mut self, symbol: Symbol, window: &mut Window, cx: &mut Context) { + let open_task = self.project.update(cx, |project, cx| { + project.open_buffer_for_symbol(&symbol, cx) + }); + + self.pending_update = cx.spawn_in(window, async move |this, cx| { + let Some(buffer) = open_task.await.log_err() else { + return; + }; + this.update_in(cx, |this, window, cx| { + let snapshot = buffer.read(cx).text_snapshot(); + let start = snapshot.clip_point_utf16(symbol.range.start, Bias::Left); + let end = snapshot.clip_point_utf16(symbol.range.end, Bias::Left); + let highlight = MatchLocation { + anchor_range: snapshot.anchor_before(start)..snapshot.anchor_after(end), + range: snapshot.point_utf16_to_offset(start) + ..snapshot.point_utf16_to_offset(end), + }; + this.update_from_buffer(buffer, Some(highlight), window, cx); + cx.notify(); + }) + .ok(); + }); } fn update_from_buffer( @@ -302,7 +333,7 @@ impl EditorPreview { div() .flex_1() .overflow_hidden() - .child(self.editor_as_giant_button()) + .child(self.occluded_editor()) .into_any_element() } } @@ -324,7 +355,7 @@ impl EditorPreview { .child(content) } - fn editor_as_giant_button(&self) -> impl IntoElement { + fn occluded_editor(&self) -> impl IntoElement { div() .relative() .size_full() @@ -334,10 +365,7 @@ impl EditorPreview { .id("picker-preview-editor") .absolute() .inset_0() - .occlude() - .on_click(|_, window, cx| { - window.dispatch_action(ToMultiBuffer.boxed_clone(), cx); - }), + .occlude(), ) } } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 5fd388ddaf8c08..b3ee5a88a22163 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -37,8 +37,8 @@ test-support = [ aho-corasick.workspace = true anyhow.workspace = true askpass.workspace = true -async-trait.workspace = true async-channel.workspace = true +async-trait.workspace = true base64.workspace = true buffer_diff.workspace = true circular-buffer.workspace = true @@ -60,15 +60,17 @@ globset.workspace = true gpui.workspace = true http_client = { workspace = true, features = ["github-download"] } image.workspace = true -itertools.workspace = true indexmap.workspace = true +itertools.workspace = true language.workspace = true log.workspace = true lsp.workspace = true markdown.workspace = true node_runtime.workspace = true parking_lot.workspace = true +path.workspace = true paths.workspace = true +percent-encoding.workspace = true postage.workspace = true prettier.workspace = true rand.workspace = true @@ -93,8 +95,8 @@ tempfile.workspace = true terminal.workspace = true text.workspace = true toml.workspace = true +tracing.workspace = true url.workspace = true -percent-encoding.workspace = true util.workspace = true watch.workspace = true wax.workspace = true @@ -104,7 +106,6 @@ zed_credentials_provider.workspace = true zeroize.workspace = true zlog.workspace = true ztracing.workspace = true -tracing.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 8cfba22f6d68ca..67550e8108b4d3 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -412,7 +412,7 @@ async fn build_registry_agents( archive: target.archive.clone(), cmd: target.cmd.clone(), args: target.args.clone(), - sha256: None, + sha256: target.sha256.clone(), env: target.env.clone(), }, ); @@ -662,6 +662,8 @@ struct RegistryBinaryTarget { #[serde(default)] args: Vec, #[serde(default)] + sha256: Option, + #[serde(default)] env: HashMap, } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 89f8b98354f450..e92c37d9df7c29 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -400,7 +400,9 @@ impl AgentServerStore { http_client: http_client.clone(), node_runtime: node_runtime.clone(), project_environment: project_environment.clone(), - registry_id: Arc::from(name.as_str()), + installation_dir: paths::external_agents_dir() + .join("registry") + .join(sanitize_path_component(name)), version: agent.metadata.version.clone(), targets: agent.targets.clone(), env: env.clone(), @@ -1022,6 +1024,7 @@ fn versioned_archive_cache_dir( base_dir: &Path, version: Option<&str>, archive_url: &str, + sha256: Option<&str>, ) -> PathBuf { let version = version.unwrap_or_default(); let sanitized_version = sanitize_path_component(version); @@ -1030,14 +1033,18 @@ fn versioned_archive_cache_dir( version_hasher.update(version.as_bytes()); let version_hash = format!("{:x}", version_hasher.finalize()); - let mut url_hasher = Sha256::new(); - url_hasher.update(archive_url.as_bytes()); - let url_hash = format!("{:x}", url_hasher.finalize()); + let mut archive_hasher = Sha256::new(); + archive_hasher.update(archive_url.as_bytes()); + if let Some(sha256) = sha256 { + archive_hasher.update(b"\0sha256:"); + archive_hasher.update(sha256.to_ascii_lowercase().as_bytes()); + } + let archive_hash = format!("{:x}", archive_hasher.finalize()); base_dir.join(format!( "v_{sanitized_version}_{}_{}", &version_hash[..16], - &url_hash[..16], + &archive_hash[..16], )) } @@ -1112,7 +1119,7 @@ struct LocalRegistryArchiveAgent { http_client: Arc, node_runtime: NodeRuntime, project_environment: Entity, - registry_id: Arc, + installation_dir: PathBuf, version: SharedString, targets: HashMap, env: HashMap, @@ -1151,7 +1158,7 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { let http_client = self.http_client.clone(); let node_runtime = self.node_runtime.clone(); let project_environment = self.project_environment.downgrade(); - let registry_id = self.registry_id.clone(); + let installation_dir = self.installation_dir.clone(); let targets = self.targets.clone(); let settings_env = self.env.clone(); let version = self.version.clone(); @@ -1165,9 +1172,7 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { .await .unwrap_or_default(); - let dir = paths::external_agents_dir() - .join("registry") - .join(sanitize_path_component(®istry_id)); + let dir = installation_dir; fs.create_dir(&dir).await?; let os = if cfg!(target_os = "macos") { @@ -1206,8 +1211,12 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { env.extend(settings_env); let archive_url = &target_config.archive; - let version_dir = - versioned_archive_cache_dir(&dir, Some(version.as_ref()), archive_url); + let version_dir = versioned_archive_cache_dir( + &dir, + Some(version.as_ref()), + archive_url, + target_config.sha256.as_deref(), + ); if !fs.is_dir(&version_dir).await { let mut loading_status_tx = loading_status_tx; @@ -1669,9 +1678,75 @@ mod tests { }; use crate::worktree_store::{WorktreeIdCounter, WorktreeStore}; use gpui::TestAppContext; + #[cfg(feature = "test-support")] + use http_client::{AsyncBody, FakeHttpClient, Response}; use node_runtime::NodeRuntime; use settings::Settings as _; + #[cfg(feature = "test-support")] + const TEST_ARCHIVE_URL: &str = "https://example.test/agent"; + + #[cfg(feature = "test-support")] + fn static_http_client(body: Vec) -> Arc { + FakeHttpClient::create(move |_| { + let body = body.clone(); + async move { + Ok(Response::builder() + .status(200) + .body(AsyncBody::from(body))?) + } + }) + } + + #[cfg(feature = "test-support")] + fn make_registry_archive_agent( + cx: &mut TestAppContext, + installation_dir: PathBuf, + http_client: Arc, + sha256: Option, + ) -> LocalRegistryArchiveAgent { + let fs: Arc = Arc::new(fs::RealFs::new(None, cx.executor())); + let target = RegistryTargetConfig { + archive: TEST_ARCHIVE_URL.to_string(), + cmd: "./agent".to_string(), + args: Vec::new(), + sha256, + env: HashMap::default(), + }; + let targets = [ + "darwin-aarch64", + "darwin-x86_64", + "linux-aarch64", + "linux-x86_64", + "windows-aarch64", + "windows-x86_64", + ] + .into_iter() + .map(|platform| (platform.to_string(), target.clone())) + .collect(); + + cx.update(|cx| { + let worktree_store = + cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); + let project_environment = cx.new(|cx| { + crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) + }); + + LocalRegistryArchiveAgent { + fs, + http_client, + node_runtime: NodeRuntime::unavailable(), + project_environment, + installation_dir, + version: "1.0.0".into(), + targets, + env: HashMap::default(), + new_version_available_tx: None, + loading_status_tx: None, + } + }) + } + fn make_npx_agent(id: &str, version: &str) -> RegistryAgent { let id = SharedString::from(id.to_string()); RegistryAgent::Npx(RegistryNpxAgent { @@ -1896,16 +1971,18 @@ mod tests { } #[test] - fn versioned_archive_cache_dir_includes_version_before_url_hash() { + fn versioned_archive_cache_dir_includes_artifact_identity() { let slash_version_dir = versioned_archive_cache_dir( Path::new("/tmp/agents"), Some("release/2.3.5"), "https://example.com/agent.zip", + None, ); let colon_version_dir = versioned_archive_cache_dir( Path::new("/tmp/agents"), Some("release:2.3.5"), "https://example.com/agent.zip", + None, ); let file_name = slash_version_dir .file_name() @@ -1914,6 +1991,129 @@ mod tests { assert!(file_name.starts_with("v_release-2.3.5_")); assert_ne!(slash_version_dir, colon_version_dir); + + let lowercase_checksum_dir = versioned_archive_cache_dir( + Path::new("/tmp/agents"), + Some("release/2.3.5"), + "https://example.com/agent.zip", + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ); + let uppercase_checksum_dir = versioned_archive_cache_dir( + Path::new("/tmp/agents"), + Some("release/2.3.5"), + "https://example.com/agent.zip", + Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + ); + let changed_checksum_dir = versioned_archive_cache_dir( + Path::new("/tmp/agents"), + Some("release/2.3.5"), + "https://example.com/agent.zip", + Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ); + + assert_ne!(slash_version_dir, lowercase_checksum_dir); + assert_eq!(lowercase_checksum_dir, uppercase_checksum_dir); + assert_ne!(lowercase_checksum_dir, changed_checksum_dir); + } + + #[cfg(feature = "test-support")] + #[gpui::test] + async fn registry_raw_binary_checksum_invalidates_unverified_cache_and_blocks_mismatch( + cx: &mut TestAppContext, + ) { + init_test_settings(cx); + cx.executor().allow_parking(); + let temp_dir = tempfile::tempdir().unwrap(); + let installation_dir = temp_dir.path().join("agent"); + let old_version_dir = + versioned_archive_cache_dir(&installation_dir, Some("1.0.0"), TEST_ARCHIVE_URL, None); + std::fs::create_dir_all(&old_version_dir).unwrap(); + std::fs::write(old_version_dir.join("agent"), b"unverified agent").unwrap(); + + let expected_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"; + let http_client = static_http_client(b"unexpected agent".to_vec()); + let mut agent = make_registry_archive_agent( + cx, + installation_dir.clone(), + http_client, + Some(expected_sha256.to_string()), + ); + let get_command = + cx.update(|cx| agent.get_command(Vec::new(), HashMap::default(), &mut cx.to_async())); + + let error = get_command.await.unwrap_err(); + assert!( + error.to_string().contains("SHA-256 mismatch"), + "unexpected error: {error:#}" + ); + assert!(old_version_dir.exists()); + assert!( + !versioned_archive_cache_dir( + &installation_dir, + Some("1.0.0"), + TEST_ARCHIVE_URL, + Some(expected_sha256), + ) + .exists() + ); + } + + #[cfg(feature = "test-support")] + #[gpui::test] + async fn registry_raw_binary_with_checksum_installs(cx: &mut TestAppContext) { + init_test_settings(cx); + cx.executor().allow_parking(); + let temp_dir = tempfile::tempdir().unwrap(); + let installation_dir = temp_dir.path().join("agent"); + let contents = b"verified agent"; + let expected_sha256 = format!("{:X}", Sha256::digest(contents)); + let http_client = static_http_client(contents.to_vec()); + let mut agent = make_registry_archive_agent( + cx, + installation_dir.clone(), + http_client, + Some(expected_sha256.clone()), + ); + let get_command = + cx.update(|cx| agent.get_command(Vec::new(), HashMap::default(), &mut cx.to_async())); + + let command = get_command.await.unwrap(); + cx.run_until_parked(); + assert_eq!( + command.path, + versioned_archive_cache_dir( + &installation_dir, + Some("1.0.0"), + TEST_ARCHIVE_URL, + Some(&expected_sha256), + ) + .join("agent") + ); + assert_eq!(std::fs::read(command.path).unwrap(), contents); + } + + #[cfg(feature = "test-support")] + #[gpui::test] + async fn registry_raw_binary_without_checksum_installs(cx: &mut TestAppContext) { + init_test_settings(cx); + cx.executor().allow_parking(); + let temp_dir = tempfile::tempdir().unwrap(); + let installation_dir = temp_dir.path().join("agent"); + let contents = b"unchecked agent"; + let http_client = static_http_client(contents.to_vec()); + let mut agent = + make_registry_archive_agent(cx, installation_dir.clone(), http_client, None); + let get_command = + cx.update(|cx| agent.get_command(Vec::new(), HashMap::default(), &mut cx.to_async())); + + let command = get_command.await.unwrap(); + cx.run_until_parked(); + assert_eq!( + command.path, + versioned_archive_cache_dir(&installation_dir, Some("1.0.0"), TEST_ARCHIVE_URL, None,) + .join("agent") + ); + assert_eq!(std::fs::read(command.path).unwrap(), contents); } #[gpui::test] diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index f9076753998e93..9fa7c324dff63a 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -312,7 +312,7 @@ impl RemoteBufferStore { .request(proto::OpenBufferByPath { project_id, worktree_id, - path: path.to_proto(), + path: path.as_unix_str().to_owned(), }) .await?; let buffer_id = BufferId::new(response.buffer_id)?; @@ -647,6 +647,12 @@ impl LocalBufferStore { let path = path.clone(); let buffer = match load_file.await { Ok(loaded) => { + let is_writable = loaded.is_writable; + let capability = if is_writable { + Capability::ReadWrite + } else { + Capability::Read + }; let reservation = cx.reserve_entity::(); let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); let text_buffer = cx @@ -655,8 +661,7 @@ impl LocalBufferStore { }) .await; cx.insert_entity(reservation, |_| { - let mut buffer = - Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite); + let mut buffer = Buffer::build(text_buffer, Some(loaded.file), capability); buffer.set_encoding(loaded.encoding); buffer.set_has_bom(loaded.has_bom); buffer diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index ef5077d53a2fa3..9e3a65134dc510 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; use context_server::oauth::{self, McpOAuthTokenProvider, OAuthDiscovery, OAuthSession}; -use context_server::transport::{HttpTransport, TransportError}; +use context_server::transport::HttpTransport; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use credentials_provider::CredentialsProvider; use futures::future::Either; @@ -22,7 +22,7 @@ use rand::Rng as _; use registry::ContextServerDescriptorRegistry; use remote::{Interactive, RemoteClient}; use rpc::{AnyProtoClient, TypedEnvelope, proto}; -use settings::{Settings as _, SettingsStore}; +use settings::{Settings as _, SettingsLocation, SettingsStore, WorktreeId}; use util::{ResultExt as _, rel_path::RelPath}; use crate::{ @@ -93,6 +93,9 @@ enum ContextServerState { Running { server: Arc, configuration: Arc, + /// Initiates the OAuth flow if the transport shuts down on an + /// authentication challenge; cancelled by any state transition. + _transport_watch: Task<()>, }, Stopped { server: Arc, @@ -277,9 +280,15 @@ enum ContextServerStoreState { }, } +#[derive(Clone, PartialEq)] +struct ContextServerSettingsEntry { + worktree_id: Option, + settings: ContextServerSettings, +} + pub struct ContextServerStore { state: ContextServerStoreState, - context_server_settings: HashMap, ContextServerSettings>, + context_server_settings: HashMap, ContextServerSettingsEntry>, servers: HashMap, server_ids: Vec, worktree_store: Entity, @@ -362,7 +371,7 @@ impl ContextServerStore { pub fn configured_server_ids(&self) -> Vec { self.context_server_settings .iter() - .filter(|(_, settings)| settings.enabled()) + .filter(|(_, entry)| entry.settings.enabled()) .map(|(id, _)| ContextServerId(id.clone())) .collect() } @@ -448,12 +457,11 @@ impl ContextServerStore { let ai_was_disabled = this.ai_disabled; this.ai_disabled = ai_disabled; - let settings = - &Self::resolve_project_settings(&this.worktree_store, cx).context_servers; - let settings_changed = &this.context_server_settings != settings; + let settings = Self::resolve_all_context_server_settings(&this.worktree_store, cx); + let settings_changed = this.context_server_settings != settings; if settings_changed { - this.context_server_settings = settings.clone(); + this.context_server_settings = settings; } // When AI is disabled, stop all running servers @@ -493,9 +501,7 @@ impl ContextServerStore { let mut this = Self { state, _subscriptions: subscriptions, - context_server_settings: Self::resolve_project_settings(&worktree_store, cx) - .context_servers - .clone(), + context_server_settings: Self::resolve_all_context_server_settings(&worktree_store, cx), worktree_store, project: weak_project, registry, @@ -539,7 +545,9 @@ impl ContextServerStore { /// or project settings. This is available regardless of whether the server is /// currently running, unlike [`Self::configuration_for_server`]. pub fn settings_for_server(&self, id: &ContextServerId) -> Option<&ContextServerSettings> { - self.context_server_settings.get(&id.0) + self.context_server_settings + .get(&id.0) + .map(|entry| &entry.settings) } /// Returns whether a server is provided by an extension (as opposed to a @@ -549,7 +557,7 @@ impl ContextServerStore { /// configuration, so it stays correct even when a custom server is disabled /// or has not been started yet (in which case it has no runtime state). pub fn is_extension_provided(&self, id: &ContextServerId, cx: &App) -> bool { - match self.context_server_settings.get(&id.0) { + match self.settings_for_server(id) { Some(ContextServerSettings::Stdio { .. } | ContextServerSettings::Http { .. }) => false, Some(ContextServerSettings::Extension { .. }) => true, // No custom settings entry: the server can only originate from an @@ -562,6 +570,21 @@ impl ContextServerStore { } } + /// Returns whether a server is enabled. + /// Servers with no settings entry only originate from an extension + /// descriptor in the registry, and those are enabled by default + /// ([`ContextServerSettings::default_extension`]). + pub fn is_server_enabled(&self, id: &ContextServerId, cx: &App) -> bool { + match self.settings_for_server(id) { + Some(settings) => settings.enabled(), + None => self + .registry + .read(cx) + .context_server_descriptor(&id.0) + .is_some(), + } + } + /// Returns a sorted slice of available unique context server IDs. Within the /// slice, context servers which have `mcp-server-` as a prefix in their ID will /// appear after servers that do not have this prefix in their ID. @@ -621,13 +644,13 @@ impl ContextServerStore { cx.spawn(async move |this, cx| { let this = this.upgrade().context("Context server store dropped")?; let id = server.id(); - let settings = this + let settings_entry = this .update(cx, |this, _| { this.context_server_settings.get(&id.0).cloned() }) .context("Failed to get context server settings")?; - if !settings.enabled() { + if !settings_entry.settings.enabled() { return anyhow::Ok(()); } @@ -635,7 +658,7 @@ impl ContextServerStore { (this.registry.clone(), this.worktree_store.clone()) }); let configuration = ContextServerConfiguration::from_settings( - settings, + settings_entry.settings, id.clone(), registry, worktree_store, @@ -708,9 +731,12 @@ impl ContextServerStore { let new_state = match server.clone().start(cx).await { Ok(_) => { debug_assert!(server.client().is_some()); + let _transport_watch = + Self::watch_transport_shutdown(this.clone(), server.clone(), cx); ContextServerState::Running { server, configuration, + _transport_watch, } } Err(err) => resolve_start_failure(&id, err, server, configuration, cx).await, @@ -733,6 +759,97 @@ impl ContextServerStore { ); } + /// Watches a running server's transport and initiates the OAuth flow if it + /// shuts down on an authentication challenge. + /// + /// MCP servers may accept `initialize` unauthenticated and only send a 401 + /// with a `WWW-Authenticate` challenge on a later request or notification. + /// The HTTP transport records the challenge, and the failed send tears + /// down the client's output loop. Observing that shutdown — rather than + /// relying on some request to carry a typed error back to a caller — is + /// what lets any post-initialize 401 move the server into `AuthRequired` + /// instead of leaving it `Running` with a dead client. + fn watch_transport_shutdown( + this: WeakEntity, + server: Arc, + cx: &mut AsyncApp, + ) -> Task<()> { + let Some(shutdown) = server + .client() + .and_then(|client| client.wait_for_shutdown()) + else { + return Task::ready(()); + }; + cx.spawn(async move |cx| { + let Some(www_authenticate) = shutdown.await else { + // Non-auth transport deaths leave the server state untouched, + // as they did before this watch existed. + return; + }; + this.update(cx, |this, cx| { + this.handle_auth_challenge(server, www_authenticate, cx); + }) + .log_err(); + }) + } + + fn handle_auth_challenge( + &mut self, + server: Arc, + www_authenticate: oauth::WwwAuthenticate, + cx: &mut Context, + ) { + let id = server.id(); + + // Act only if this exact server is still the one we consider running. + // If the state has changed since the challenge was recorded, whoever + // changed it owns the lifecycle now. + let Some(ContextServerState::Running { + server: running_server, + configuration, + .. + }) = self.servers.get(&id) + else { + return; + }; + if !Arc::ptr_eq(running_server, &server) { + return; + } + let configuration = configuration.clone(); + + log::info!("{id} received 401 after initialization; initiating OAuth authorization"); + + // The 401 already tore down the client's output loop. Stop the dead + // client, then resolve auth using the captured `WWW-Authenticate` — do + // not restart via `run_server`, as a fresh `initialize` would succeed + // and lose the challenge. + server.stop().log_err(); + + let task = cx.spawn({ + let id = id.clone(); + let server = server.clone(); + let configuration = configuration.clone(); + async move |this, cx| { + let new_state = + resolve_auth_required(&id, &www_authenticate, server, configuration, cx).await; + this.update(cx, |this, cx| { + this.update_server_state(id.clone(), new_state, cx) + }) + .log_err(); + } + }); + + self.update_server_state( + id, + ContextServerState::Starting { + configuration, + _task: task, + server, + }, + cx, + ); + } + fn remove_server(&mut self, id: &ContextServerId, cx: &mut Context) -> Result<()> { let state = self .servers @@ -757,6 +874,7 @@ impl ContextServerStore { server_id: id.clone(), status: ContextServerStatus::Stopped, }); + cx.notify(); Ok(()) } @@ -884,8 +1002,7 @@ impl ContextServerStore { }; let server: Arc = this.update(cx, |this, cx| { - let global_timeout = - Self::resolve_project_settings(&this.worktree_store, cx).context_server_timeout; + let global_timeout = this.timeout_for_server(&id, cx); match configuration.as_ref() { ContextServerConfiguration::Http { @@ -942,31 +1059,37 @@ impl ContextServerStore { ) -> Result { let server_id = ContextServerId(envelope.payload.server_id.into()); - let (settings, registry, worktree_store) = this.update(&mut cx, |this, inner_cx| { - let ContextServerStoreState::Local { - is_headless: true, .. - } = &this.state - else { - anyhow::bail!("unexpected GetContextServerCommand request in a non-local project"); - }; + let (settings_entry, registry, worktree_store) = + this.update(&mut cx, |this, inner_cx| { + let ContextServerStoreState::Local { + is_headless: true, .. + } = &this.state + else { + anyhow::bail!( + "unexpected GetContextServerCommand request in a non-local project" + ); + }; - let settings = this - .context_server_settings - .get(&server_id.0) - .cloned() - .or_else(|| { - this.registry - .read(inner_cx) - .context_server_descriptor(&server_id.0) - .map(|_| ContextServerSettings::default_extension()) - }) - .with_context(|| format!("context server `{}` not found", server_id))?; + let settings = this + .context_server_settings + .get(&server_id.0) + .cloned() + .or_else(|| { + this.registry + .read(inner_cx) + .context_server_descriptor(&server_id.0) + .map(|_| ContextServerSettingsEntry { + worktree_id: None, + settings: ContextServerSettings::default_extension(), + }) + }) + .with_context(|| format!("context server `{}` not found", server_id))?; - anyhow::Ok((settings, this.registry.clone(), this.worktree_store.clone())) - })?; + anyhow::Ok((settings, this.registry.clone(), this.worktree_store.clone())) + })?; let configuration = ContextServerConfiguration::from_settings( - settings, + settings_entry.settings, server_id.clone(), registry, worktree_store, @@ -990,19 +1113,29 @@ impl ContextServerStore { }) } - fn resolve_project_settings<'a>( - worktree_store: &'a Entity, - cx: &'a App, - ) -> &'a ProjectSettings { - let location = worktree_store - .read(cx) - .visible_worktrees(cx) - .next() - .map(|worktree| settings::SettingsLocation { - worktree_id: worktree.read(cx).id(), + /// Merges context server settings from all visible worktrees so that servers defined + /// in any project folder in a multi-root workspace are picked up. + fn resolve_all_context_server_settings( + worktree_store: &Entity, + cx: &App, + ) -> HashMap, ContextServerSettingsEntry> { + let mut merged = HashMap::default(); + for worktree in worktree_store.read(cx).visible_worktrees(cx) { + let worktree_id = worktree.read(cx).id(); + let location = settings::SettingsLocation { + worktree_id, path: RelPath::empty(), - }); - ProjectSettings::get(location, cx) + }; + for (id, settings) in &ProjectSettings::get(Some(location), cx).context_servers { + merged + .entry(id.clone()) + .or_insert_with(|| ContextServerSettingsEntry { + worktree_id: Some(worktree_id), + settings: settings.clone(), + }); + } + } + merged } fn create_oauth_token_provider( @@ -1037,6 +1170,23 @@ impl ContextServerStore { )) } + fn timeout_for_server(&self, id: &ContextServerId, cx: &App) -> u64 { + let worktree_id = self + .context_server_settings + .get(&id.0) + .as_ref() + .and_then(|entry| entry.worktree_id); + + ProjectSettings::get( + worktree_id.map(|id| SettingsLocation { + worktree_id: id, + path: &RelPath::empty(), + }), + cx, + ) + .context_server_timeout + } + /// Initiate the OAuth browser flow for a server in the `AuthRequired` state. /// /// This starts a loopback HTTP callback server on an ephemeral port, builds @@ -1049,6 +1199,7 @@ impl ContextServerStore { cx: &mut Context, ) -> Result<()> { let state = self.servers.get(id).context("Context server not found")?; + let global_timeout = self.timeout_for_server(id, cx); let (discovery, server, configuration) = match state { ContextServerState::AuthRequired { @@ -1107,6 +1258,7 @@ impl ContextServerStore { id.clone(), discovery.clone(), configuration.clone(), + global_timeout, cx, ) .await; @@ -1150,6 +1302,7 @@ impl ContextServerStore { cx: &mut Context, ) -> Result<()> { let state = self.servers.get(id).context("Context server not found")?; + let global_timeout = self.timeout_for_server(id, cx); let (server, configuration, discovery) = match state { ContextServerState::ClientSecretRequired { @@ -1193,6 +1346,7 @@ impl ContextServerStore { id.clone(), discovery.clone(), configuration.clone(), + global_timeout, cx, ) .await; @@ -1262,6 +1416,7 @@ impl ContextServerStore { id: ContextServerId, discovery: Arc, configuration: Arc, + global_timeout: u64, cx: &mut AsyncApp, ) -> Result<()> { let resource = oauth::canonical_server_uri(&discovery.resource_metadata.resource); @@ -1362,34 +1517,29 @@ impl ContextServerStore { cx, ); - let new_server = this.update(cx, |this, cx| { - let global_timeout = - Self::resolve_project_settings(&this.worktree_store, cx).context_server_timeout; - - match configuration.as_ref() { - ContextServerConfiguration::Http { - url, - headers, - timeout, - oauth: _, - } => { - let transport = HttpTransport::new_with_token_provider( - http_client.clone(), - url.to_string(), - headers.clone(), - cx.background_executor().clone(), - Some(token_provider.clone()), - ); - Ok(Arc::new(ContextServer::new_with_timeout( - id.clone(), - Arc::new(transport), - Some(Duration::from_secs( - timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_SECS), - )), - ))) - } - _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), + let new_server = this.update(cx, |_this, cx| match configuration.as_ref() { + ContextServerConfiguration::Http { + url, + headers, + timeout, + oauth: _, + } => { + let transport = HttpTransport::new_with_token_provider( + http_client.clone(), + url.to_string(), + headers.clone(), + cx.background_executor().clone(), + Some(token_provider.clone()), + ); + Ok(Arc::new(ContextServer::new_with_timeout( + id.clone(), + Arc::new(transport), + Some(Duration::from_secs( + timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_SECS), + )), + ))) } + _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), })??; this.update(cx, |this, cx| { @@ -1533,6 +1683,7 @@ impl ContextServerStore { server_id: id, status, }); + cx.notify(); } fn available_context_servers_changed(&mut self, cx: &mut Context) { @@ -1584,29 +1735,33 @@ impl ContextServerStore { for (id, _) in registry.read_with(cx, |registry, _| registry.context_server_descriptors()) { configured_servers .entry(id) - .or_insert(ContextServerSettings::default_extension()); + .or_insert(ContextServerSettingsEntry { + worktree_id: None, + settings: ContextServerSettings::default_extension(), + }); } let (enabled_servers, disabled_servers): (HashMap<_, _>, HashMap<_, _>) = configured_servers .into_iter() - .partition(|(_, settings)| settings.enabled()); + .partition(|(_, entry)| entry.settings.enabled()); - let configured_servers = join_all(enabled_servers.into_iter().map(|(id, settings)| { - let id = ContextServerId(id); - ContextServerConfiguration::from_settings( - settings, - id.clone(), - registry.clone(), - worktree_store.clone(), - cx, - ) - .map(move |config| (id, config)) - })) - .await - .into_iter() - .filter_map(|(id, config)| config.map(|config| (id, config))) - .collect::>(); + let configured_servers = + join_all(enabled_servers.into_iter().map(|(id, settings_entry)| { + let id = ContextServerId(id); + ContextServerConfiguration::from_settings( + settings_entry.settings, + id.clone(), + registry.clone(), + worktree_store.clone(), + cx, + ) + .map(move |config| (id, config)) + })) + .await + .into_iter() + .filter_map(|(id, config)| config.map(|config| (id, config))) + .collect::>(); let mut servers_to_start = Vec::new(); let mut servers_to_remove = HashSet::default(); @@ -1686,43 +1841,34 @@ async fn resolve_start_failure( configuration: Arc, cx: &AsyncApp, ) -> ContextServerState { - let www_authenticate = err.downcast_ref::().map(|e| match e { - TransportError::AuthRequired { www_authenticate } => www_authenticate.clone(), - }); - - if www_authenticate.is_some() && configuration.has_static_auth_header() { - log::warn!("{id} received 401 with a static Authorization header configured"); - return ContextServerState::Error { - configuration, - server, - error: "Server returned 401 Unauthorized. Check your configured Authorization header." - .into(), - }; - } - - let server_url = match configuration.as_ref() { - ContextServerConfiguration::Http { url, .. } if !configuration.has_static_auth_header() => { - url.clone() - } - _ => { - if www_authenticate.is_some() { - log::error!("{id} got OAuth 401 on a non-HTTP transport or with static auth"); - } else { - log::error!("{id} context server failed to start: {err}"); - } - return ContextServerState::Error { - configuration, - server, - error: err.to_string().into(), - }; - } - }; + // Read the challenge from the transport rather than downcasting `err`: it + // is recorded before the failed send's error propagates, so a 401 is + // recognized even when another error (e.g. the request timeout) wins the + // race to become the reported startup failure. + let www_authenticate = server.auth_challenge(); // When the error is NOT a 401 but there is a cached OAuth session in the // keychain, the session is likely stale/expired and caused the failure // (e.g. timeout because the server rejected the token silently). Clear it // so the next start attempt can get a clean 401 and trigger the auth flow. + // If there is no such session this is an ordinary startup error. if www_authenticate.is_none() { + let server_url = match configuration.as_ref() { + ContextServerConfiguration::Http { url, .. } + if !configuration.has_static_auth_header() => + { + url.clone() + } + _ => { + log::error!("{id} context server failed to start: {err}"); + return ContextServerState::Error { + configuration, + server, + error: err.to_string().into(), + }; + } + }; + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); match ContextServerStore::load_session(&credentials_provider, &server_url, cx).await { Ok(Some(_)) => { @@ -1751,6 +1897,46 @@ async fn resolve_start_failure( let www_authenticate = www_authenticate .as_ref() .unwrap_or(&default_www_authenticate); + + resolve_auth_required(id, www_authenticate, server, configuration, cx).await +} + +/// Runs OAuth discovery for a server that returned a 401 and produces the +/// appropriate state (`AuthRequired`, `ClientSecretRequired`, or `Error`). +/// +/// Shared by the startup path ([`resolve_start_failure`]) and the +/// post-initialize path ([`ContextServerStore::handle_auth_challenge`]) so +/// that a 401 at any point — not only during `initialize` — can initiate the +/// OAuth flow. +async fn resolve_auth_required( + id: &ContextServerId, + www_authenticate: &oauth::WwwAuthenticate, + server: Arc, + configuration: Arc, + cx: &AsyncApp, +) -> ContextServerState { + if configuration.has_static_auth_header() { + log::warn!("{id} received 401 with a static Authorization header configured"); + return ContextServerState::Error { + configuration, + server, + error: "Server returned 401 Unauthorized. Check your configured Authorization header." + .into(), + }; + } + + let server_url = match configuration.as_ref() { + ContextServerConfiguration::Http { url, .. } => url.clone(), + _ => { + log::error!("{id} got OAuth 401 on a non-HTTP transport"); + return ContextServerState::Error { + configuration, + server, + error: "Server returned 401 Unauthorized on a non-HTTP transport".into(), + }; + } + }; + let http_client = cx.update(|cx| cx.http_client()); match context_server::oauth::discover(&http_client, &server_url, www_authenticate).await { diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 185722034a2097..1f2e10cd337f4e 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -2534,8 +2534,8 @@ impl Session { return }; - for scope in scopes.iter() { - this.variables(scope.variables_reference, cx); + for scope in scopes.iter().filter(|scope| !scope.expensive) { + this.variables(scope.variables_reference, cx); } let entry = this diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 1edd3780307ecb..f96df2894849f1 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1,5 +1,5 @@ -pub mod branch_diff; mod conflict_set; +pub mod diff_buffer_list; pub mod git_traversal; pub mod job_debug_queue; pub mod pending_op; @@ -15,7 +15,7 @@ use crate::{ }; use anyhow::{Context as _, Result, anyhow, bail}; use askpass::{AskPassDelegate, EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs}; -use buffer_diff::{BufferDiff, BufferDiffEvent}; +use buffer_diff::{BufferDiff, DiffHunk, DiffHunkSecondaryStatus, PendingHunk, PendingSense}; use client::ProjectId; use collections::HashMap; pub use conflict_set::{ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate}; @@ -35,10 +35,11 @@ use git::{ parse_git_remote_url, repository::{ Branch, BranchesScanResult, CommitData, CommitDetails, CommitDiff, CommitFile, - CommitOptions, CreateWorktreeTarget, DiffType, FetchOptions, FileHistoryChangedFileSets, - GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, - LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, - SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, delete_branch_flag, + CommitOptions, CreateWorktreeTarget, DiffStatType, DiffType, FetchOptions, + FileHistoryChangedFileSets, GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, + InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, + RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, + delete_branch_flag, }, stash::{GitStash, StashEntry}, status::{ @@ -51,7 +52,7 @@ use gpui::{ Subscription, Task, TaskExt, WeakEntity, }; use language::{ - Buffer, BufferEvent, Capability, Language, LanguageRegistry, + Anchor, Buffer, BufferEvent, Capability, Language, LanguageRegistry, proto::{deserialize_version, serialize_version}, }; use parking_lot::Mutex; @@ -82,7 +83,7 @@ use std::{ }; use sum_tree::{Edit, SumTree, TreeMap}; use task::Shell; -use text::{Bias, BufferId}; +use text::{Bias, BufferId, OffsetRangeExt, Rope, ToOffset}; use util::{ ResultExt, debug_panic, paths::{PathStyle, SanitizedPath}, @@ -106,6 +107,7 @@ pub struct GitStore { loading_diffs: HashMap<(BufferId, DiffKind), Shared, Arc>>>>, diffs: HashMap>, + buffer_ids_by_index_text_buffer_id: HashMap, shared_diffs: HashMap>, _subscriptions: Vec, } @@ -142,6 +144,16 @@ struct BufferGitState { head_text: Option>, index_text: Option>, + /// The optimistic, in-flight index state: the sole input to the index write, + /// expressed relative to the currently-loaded index text (`I0`). Never shown + /// to any view (views use per-diff pending hunks). Cleared when a + /// recalculation settles. + /// + /// `I0` is immutable for the lifetime of any pending edit (the index text + /// buffer is only fast-forwarded when a recalculation settles, which clears + /// this state in the same update), so byte offsets stay valid for the whole + /// window. + pending_index_edits: Option, Arc)>>, oid_texts: HashMap>, head_text_buffer: WeakEntity, index_text_buffer: WeakEntity, @@ -151,6 +163,24 @@ struct BufferGitState { language_changed: bool, } +fn pending_hunks( + hunks: &[DiffHunk], + version: &clock::Global, + sense: PendingSense, +) -> Vec { + hunks + .iter() + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range.clone(), + hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + ) + }) + .collect() +} + #[derive(Clone, Debug)] enum DiffBasesChange { SetIndex(Option), @@ -170,14 +200,80 @@ enum DiffKind { SinceOid(Option), } -#[derive(Debug, Default, Clone, Copy)] +struct IndexTextFile { + path: Arc, + full_path: PathBuf, + path_style: PathStyle, + file_name: String, + worktree_id: WorktreeId, + is_private: bool, +} + +impl IndexTextFile { + fn new(file: &dyn language::File, cx: &App) -> Self { + Self { + path: file.path().clone(), + full_path: file.full_path(cx), + path_style: file.path_style(cx), + file_name: file.file_name(cx).to_string(), + worktree_id: file.worktree_id(cx), + is_private: file.is_private(), + } + } +} + +impl language::File for IndexTextFile { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> language::DiskState { + language::DiskState::Historic { was_deleted: false } + } + + fn path(&self) -> &Arc { + &self.path + } + + fn full_path(&self, _: &App) -> PathBuf { + self.full_path.clone() + } + + fn path_style(&self, _: &App) -> PathStyle { + self.path_style + } + + fn file_name<'a>(&'a self, _: &'a App) -> &'a str { + &self.file_name + } + + fn worktree_id(&self, _: &App) -> WorktreeId { + self.worktree_id + } + + fn to_proto(&self, _: &App) -> rpc::proto::File { + rpc::proto::File { + worktree_id: self.worktree_id.to_proto(), + entry_id: None, + path: self.path.as_ref().as_unix_str().to_owned(), + mtime: None, + is_deleted: false, + is_historic: true, + } + } + + fn is_private(&self) -> bool { + self.is_private + } +} + +#[derive(Debug, Clone, Copy)] pub enum GitAccess { /// Either: /// - the user owns `.git` /// - the user doesn't own `.git`, but has both of: /// - OS-level read permissions /// - the directory is marked as safe (git config safe.directory) - #[default] Yes, /// The user is not the owner of `.git`, and one of the following is true: @@ -223,6 +319,8 @@ pub struct StatusEntry { pub repo_path: RepoPath, pub status: FileStatus, pub diff_stat: Option, + pub staged_diff_stat: Option, + pub unstaged_diff_stat: Option, } impl StatusEntry { @@ -241,11 +339,15 @@ impl StatusEntry { }; proto::StatusEntry { - repo_path: self.repo_path.to_proto(), + repo_path: self.repo_path.as_unix_str().to_owned(), simple_status, status: Some(status_to_proto(self.status)), diff_stat_added: self.diff_stat.map(|ds| ds.added), diff_stat_deleted: self.diff_stat.map(|ds| ds.deleted), + staged_diff_stat_added: self.staged_diff_stat.map(|ds| ds.added), + staged_diff_stat_deleted: self.staged_diff_stat.map(|ds| ds.deleted), + unstaged_diff_stat_added: self.unstaged_diff_stat.map(|ds| ds.added), + unstaged_diff_stat_deleted: self.unstaged_diff_stat.map(|ds| ds.deleted), } } } @@ -260,10 +362,24 @@ impl TryFrom for StatusEntry { (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), _ => None, }; + let staged_diff_stat = match (value.staged_diff_stat_added, value.staged_diff_stat_deleted) + { + (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), + _ => None, + }; + let unstaged_diff_stat = match ( + value.unstaged_diff_stat_added, + value.unstaged_diff_stat_deleted, + ) { + (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), + _ => None, + }; Ok(Self { repo_path, status, diff_stat, + staged_diff_stat, + unstaged_diff_stat, }) } } @@ -486,6 +602,7 @@ pub enum RepositoryEvent { GitWorktreeListChanged, PendingOpsChanged { pending_ops: SumTree }, GraphEvent((LogSource, LogOrder), GitGraphEvent), + GitDirectoryChanged, } #[derive(Clone, Debug)] @@ -648,6 +765,7 @@ impl GitStore { loading_diffs: HashMap::default(), shared_diffs: HashMap::default(), diffs: HashMap::default(), + buffer_ids_by_index_text_buffer_id: HashMap::default(), } } @@ -683,6 +801,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_diff_checkpoints); client.add_entity_request_handler(Self::handle_load_commit_diff); client.add_entity_request_handler(Self::handle_checkout_files); + client.add_entity_request_handler(Self::handle_add_path_to_gitignore); + client.add_entity_request_handler(Self::handle_add_path_to_git_info_exclude); client.add_entity_request_handler(Self::handle_open_commit_message_buffer); client.add_entity_request_handler(Self::handle_set_index_text); client.add_entity_request_handler(Self::handle_askpass); @@ -897,6 +1017,16 @@ impl GitStore { .as_ref() .and_then(|weak| weak.upgrade()) { + // If this unstaged diff was first opened as the uncommitted diff's + // secondary, its index text wasn't highlighted. Enable it now and + // recalc so the language gets applied to the deleted (base) side. + diff_state.update(cx, |diff_state, cx| { + if !diff_state.index_text_buffer_language_enabled { + diff_state.index_text_buffer_language_enabled = true; + let buffer_snapshot = buffer.read(cx).text_snapshot(); + diff_state.recalculate_diffs(buffer_snapshot, cx); + } + }); if let Some(task) = diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) { @@ -944,15 +1074,17 @@ impl GitStore { cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) } + /// Opens the staged (HEAD-vs-index) diff for the given buffer, along with + /// the index text buffer that is the diff's main buffer. pub fn open_staged_diff( &mut self, buffer: Entity, cx: &mut Context, - ) -> Task>> { + ) -> Task, Entity)>> { let buffer_id = buffer.read(cx).remote_id(); if let Some(diff_state) = self.diffs.get(&buffer_id) - && let Some(staged_diff) = diff_state.read(cx).staged_diff() + && let Some(staged_diff) = diff_state.read(cx).staged_diff_and_index_text_buffer() { if let Some(task) = diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) @@ -988,7 +1120,270 @@ impl GitStore { }) .clone(); - cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) + cx.spawn(async move |this, cx| { + let diff = task.await.map_err(|e| anyhow!("{e}"))?; + this.update(cx, |this, cx| { + let index_text_buffer = this + .diffs + .get(&buffer_id) + .and_then(|diff_state| { + let (_, index_text_buffer) = diff_state.read(cx).staged_diff.as_ref()?; + Some(index_text_buffer.clone()) + }) + .context("index text buffer missing after opening staged diff")?; + Ok((diff, index_text_buffer)) + })? + }) + } + + /// Stages the worktree changes covered by `worktree_ranges`, acting on the + /// given unstaged (index-vs-worktree) diff. Used by both the unstaged-changes + /// view and the uncommitted (gutter) controls: "stage" means the same index + /// change regardless of which view it was invoked from, so callers holding an + /// uncommitted diff pass its unstaged secondary. + /// + /// Decomposes the worktree region into the unstaged hunks it covers, so no + /// worktree->index projection is needed. Optimistically suppresses the staged + /// hunks from the unstaged diff and, if the uncommitted diff happens to be + /// open, marks the corresponding uncommitted hunks as staging. + pub fn stage_hunks( + &mut self, + buffer: Entity, + unstaged_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if worktree_ranges.is_empty() { + return Ok(()); + } + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_id = buffer_snapshot.remote_id(); + let file_exists = buffer_snapshot + .file() + .is_some_and(|file| file.disk_state().exists()); + + let unstaged_snapshot = unstaged_diff.read(cx).snapshot(cx); + + // Decompose: the unstaged hunks the worktree region covers carry the + // index range directly. Sorting by buffer offset also sorts by index + // offset, since the hunks are non-overlapping. We read the raw hunks + // (ignoring optimistic suppression) so that re-staging a hunk that was + // optimistically staged and then unstaged still finds it. The footprints + // let the patch drop earlier edits the region covers even where the raw + // diff is empty (re-staging a hunk that is staged on disk but + // optimistically unstaged). + let mut unstaged_hunks = Vec::new(); + let mut index_footprints = Vec::new(); + for range in &worktree_ranges { + unstaged_hunks.extend( + unstaged_snapshot.raw_hunks_intersecting_range(range.clone(), &buffer_snapshot), + ); + index_footprints.push( + unstaged_snapshot.base_text_range_for_buffer_range(range.clone(), &buffer_snapshot), + ); + } + unstaged_hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + unstaged_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + // The uncommitted hunks the region covers get the optimistic + // "staging" secondary status (free cross-view update). + let uncommitted_diff = self.get_uncommitted_diff(buffer_id, cx); + let mut uncommitted_hunks = Vec::new(); + if let Some(uncommitted_diff) = &uncommitted_diff { + let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx); + for range in &worktree_ranges { + uncommitted_hunks.extend( + uncommitted_snapshot + .hunks_intersecting_range(range.clone(), &buffer_snapshot) + .filter(|hunk| { + hunk.secondary_status != DiffHunkSecondaryStatus::NoSecondaryHunk + }), + ); + } + uncommitted_hunks + .sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + uncommitted_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + } + + let index_edits = if !file_exists { + // The worktree file is gone: staging removes it from the index. + None + } else { + Some( + unstaged_hunks + .iter() + .map(|hunk| { + let worktree_range = hunk.buffer_range.to_offset(&buffer_snapshot); + let replacement: Arc = Arc::from( + buffer_snapshot + .text_for_range(worktree_range) + .collect::(), + ); + (hunk.diff_base_byte_range.clone(), replacement) + }) + .collect::>(), + ) + }; + + let version = buffer_snapshot.version().clone(); + let unstaged_pending = pending_hunks(&unstaged_hunks, &version, PendingSense::Suppress); + let uncommitted_pending = pending_hunks( + &uncommitted_hunks, + &version, + PendingSense::SetSecondaryStatus { stage: true }, + ); + drop(unstaged_snapshot); + + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + diff_state.update(cx, |diff_state, _| { + diff_state.remove_overlapping_pending_index_edits(&index_footprints); + diff_state.insert_pending_index_edits(index_edits); + }); + + if let Some(uncommitted_diff) = uncommitted_diff { + uncommitted_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&uncommitted_pending, &buffer_snapshot, cx); + }); + } + unstaged_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&unstaged_pending, &buffer_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Unstages the worktree changes covered by `worktree_ranges`, acting on the + /// given uncommitted (HEAD-vs-worktree) diff, invoked from the uncommitted + /// (gutter) controls. Uses the worktree->index projection (the hard part) + /// because the acted-on hunks are HEAD-vs-worktree. + pub fn unstage_uncommitted_hunks( + &mut self, + buffer: Entity, + uncommitted_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if worktree_ranges.is_empty() { + return Ok(()); + } + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_id = buffer_snapshot.remote_id(); + let file_exists = buffer_snapshot + .file() + .is_some_and(|file| file.disk_state().exists()); + + let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx); + let unstaged_snapshot = uncommitted_snapshot + .secondary_diff() + .context("diff has no unstaged secondary")?; + + let mut hunks = Vec::new(); + for range in &worktree_ranges { + hunks.extend( + uncommitted_snapshot.hunks_intersecting_range(range.clone(), &buffer_snapshot), + ); + } + hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + let (index_edits, pending) = uncommitted_snapshot.compute_uncommitted_index_edits( + unstaged_snapshot, + false, + &hunks, + &buffer_snapshot, + file_exists, + ); + drop(uncommitted_snapshot); + + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + diff_state.update(cx, |diff_state, _| { + diff_state.insert_pending_index_edits(index_edits) + }); + + uncommitted_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&pending, &buffer_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Unstages staged (HEAD-vs-index) hunks covered by `index_ranges` (in the + /// index text buffer's coordinates), acting on the given staged diff, + /// invoked from the staged-changes view. The acted-on hunks already carry + /// an index range, so no projection is needed; optimistically suppresses + /// them from the staged diff. + pub fn unstage_staged_hunks( + &mut self, + staged_diff: Entity, + index_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if index_ranges.is_empty() { + return Ok(()); + } + let index_buffer_id = staged_diff.read(cx).buffer_id; + let buffer_id = *self + .buffer_ids_by_index_text_buffer_id + .get(&index_buffer_id) + .context("failed to find git state for index text buffer")?; + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + let index_buffer = diff_state + .read(cx) + .index_text_buffer() + .context("index text is not loaded")?; + let index_snapshot = index_buffer.read(cx).text_snapshot(); + let staged_snapshot = staged_diff.read(cx).snapshot(cx); + + let mut hunks = Vec::new(); + for range in &index_ranges { + hunks.extend(staged_snapshot.hunks_intersecting_range(range.clone(), &index_snapshot)); + } + hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&index_snapshot)); + hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + let index_edits = staged_diff + .read(cx) + .unstage_staged_hunks(&hunks, &index_snapshot); + let version = index_snapshot.version().clone(); + let pending = pending_hunks(&hunks, &version, PendingSense::Suppress); + drop(staged_snapshot); + + diff_state.update(cx, |diff_state, _| { + diff_state.insert_pending_index_edits(index_edits) + }); + staged_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&pending, &index_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Derives the desired index text from the buffer's optimistic patch and + /// schedules the write. + fn write_optimistic_index(&mut self, buffer_id: BufferId, cx: &mut Context) { + let Some(diff_state) = self.diffs.get(&buffer_id) else { + return; + }; + let new_index_text = diff_state + .read(cx) + .pending_index_text(cx) + .map(|rope| rope.to_string()); + self.write_index_text_for_buffer_id(buffer_id, new_index_text, cx); } pub fn open_diff_since( @@ -1058,9 +1453,6 @@ impl GitStore { }); this.update(cx, |this, cx| { - cx.subscribe(&buffer_diff, Self::on_buffer_diff_event) - .detach(); - this.loading_diffs.remove(&(buffer_id, diff_kind)); let git_store = cx.weak_entity(); @@ -1174,6 +1566,9 @@ impl GitStore { let buffer_id = buffer.remote_id(); let language = buffer.language().cloned(); let language_registry = buffer.language_registry(); + let index_text_file = buffer.file().map(|file| { + Arc::new(IndexTextFile::new(file.as_ref(), cx)) as Arc + }); let text_snapshot = buffer.text_snapshot(); this.loading_diffs.remove(&(buffer_id, kind)); @@ -1194,7 +1589,7 @@ impl GitStore { let diff = match kind { DiffKind::Unstaged => { let base_text_buffer = diff_state.update(cx, |diff_state, cx| { - diff_state.get_or_create_index_text_buffer(cx) + diff_state.get_or_create_index_text_buffer(index_text_file.clone(), cx) }); cx.new(|cx| { BufferDiff::new_with_base_text_buffer( @@ -1208,7 +1603,10 @@ impl GitStore { let (index_text_buffer, base_text_buffer) = diff_state.update(cx, |diff_state, cx| { ( - diff_state.get_or_create_index_text_buffer(cx), + diff_state.get_or_create_index_text_buffer( + index_text_file.clone(), + cx, + ), diff_state.get_or_create_head_text_buffer(cx), ) }); @@ -1244,16 +1642,19 @@ impl GitStore { unreachable!("open_diff_internal is not used for OID diffs") } }; - cx.subscribe(&diff, Self::on_buffer_diff_event).detach(); diff }; - diff_state.update(cx, |diff_state, cx| { + let rx = diff_state.update(cx, |diff_state, cx| { diff_state.language = language; diff_state.language_registry = language_registry; match kind { DiffKind::Unstaged => { diff_state.unstaged_diff = Some(diff.downgrade()); + // The deleted (base) side of a standalone unstaged diff + // is the index, so highlight it. The recalc kicked off by + // `diff_bases_changed` below applies the language. + diff_state.index_text_buffer_language_enabled = true; } DiffKind::Staged => { diff_state.index_text_buffer_language_enabled = true; @@ -1266,7 +1667,8 @@ impl GitStore { let unstaged_diff = if let Some(diff) = existing_unstaged_diff { diff } else { - let base_text_buffer = diff_state.get_or_create_index_text_buffer(cx); + let base_text_buffer = + diff_state.get_or_create_index_text_buffer(index_text_file, cx); let unstaged_diff = cx.new(|cx| { BufferDiff::new_with_base_text_buffer( &text_snapshot, @@ -1295,7 +1697,15 @@ impl GitStore { } Ok(diff) }) - }) + }); + let diff_state = this.diffs.get(&buffer_id).cloned(); + if let Some(index_text_buffer) = + diff_state.and_then(|diff_state| diff_state.read(cx).index_text_buffer()) + { + this.buffer_ids_by_index_text_buffer_id + .insert(index_text_buffer.read(cx).remote_id(), buffer_id); + } + rx })?? .await } @@ -1817,13 +2227,21 @@ impl GitStore { cx: &mut Context, ) { let mut removed_ids = Vec::new(); + + let is_trusted = TrustedWorktrees::try_get_global(cx) + .map(|trusted_worktrees| { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx) + }) + }) + .unwrap_or(false); + for update in updated_git_repositories.iter() { if let Some((id, existing)) = self.repositories.iter().find(|(_, repo)| { - let existing_work_directory_abs_path = - repo.read(cx).work_directory_abs_path.clone(); - Some(&existing_work_directory_abs_path) + let existing_work_directory_abs_path = &repo.read(cx).work_directory_abs_path; + Some(existing_work_directory_abs_path) == update.old_work_directory_abs_path.as_ref() - || Some(&existing_work_directory_abs_path) + || Some(existing_work_directory_abs_path) == update.new_work_directory_abs_path.as_ref() }) { let repo_id = *id; @@ -1842,17 +2260,6 @@ impl GitStore { update.repository_dir_abs_path.clone() && let Some(common_dir_abs_path) = update.common_dir_abs_path.clone() { - let is_trusted = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees.update(cx, |trusted_worktrees, cx| { - trusted_worktrees.can_trust( - &self.worktree_store, - worktree_id, - cx, - ) - }) - }) - .unwrap_or(false); existing.update(cx, |existing, cx| { existing.reinitialize_local_backend( new_work_directory_abs_path, @@ -1888,16 +2295,7 @@ impl GitStore { .. } = update { - let repository_dir_abs_path = repository_dir_abs_path.clone(); - let common_dir_abs_path = common_dir_abs_path.clone(); let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release)); - let is_trusted = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees.update(cx, |trusted_worktrees, cx| { - trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx) - }) - }) - .unwrap_or(false); let git_store = cx.weak_entity(); let repo = cx.new(|cx| { let mut repo = Repository::local( @@ -2009,6 +2407,8 @@ impl GitStore { } BufferStoreEvent::BufferDropped(buffer_id) => { self.diffs.remove(buffer_id); + self.buffer_ids_by_index_text_buffer_id + .retain(|_, main_buffer_id| main_buffer_id != buffer_id); for diffs in self.shared_diffs.values_mut() { diffs.remove(buffer_id); } @@ -2085,48 +2485,45 @@ impl GitStore { } } - fn on_buffer_diff_event( + fn write_index_text_for_buffer_id( &mut self, - diff: Entity, - event: &BufferDiffEvent, + buffer_id: BufferId, + new_index_text: Option, cx: &mut Context, ) { - if let BufferDiffEvent::HunksStagedOrUnstaged(new_index_text) = event { - let buffer_id = diff.read(cx).buffer_id; - if let Some(diff_state) = self.diffs.get(&buffer_id) { - let new_index_text = new_index_text.as_ref().map(|rope| rope.to_string()); - if new_index_text.as_deref() == diff_state.read(cx).index_text.as_deref() { - return; - } - let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| { - diff_state.hunk_staging_operation_count += 1; - diff_state.hunk_staging_operation_count - }); - if let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) { - let recv = repo.update(cx, |repo, cx| { - log::debug!("hunks changed for {}", path.as_unix_str()); - repo.spawn_set_index_text_job( - path, - new_index_text, - Some(hunk_staging_operation_count), - cx, - ) - }); - let diff = diff.downgrade(); - cx.spawn(async move |this, cx| { - if let Ok(Err(error)) = cx.background_spawn(recv).await { - diff.update(cx, |diff, cx| { - diff.clear_pending_hunks(cx); - }) - .ok(); - this.update(cx, |_, cx| cx.emit(GitStoreEvent::IndexWriteError(error))) - .ok(); - } - }) - .detach(); - } + let Some(diff_state) = self.diffs.get(&buffer_id) else { + return; + }; + let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| { + diff_state.hunk_staging_operation_count += 1; + diff_state.hunk_staging_operation_count + }); + let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) else { + return; + }; + let recv = repo.update(cx, |repo, cx| { + log::debug!("hunks changed for {}", path.as_unix_str()); + repo.spawn_set_index_text_job( + path, + new_index_text, + Some(hunk_staging_operation_count), + cx, + ) + }); + cx.spawn(async move |this, cx| { + if let Ok(Err(error)) = cx.background_spawn(recv).await { + this.update(cx, |this, cx| { + if let Some(diff_state) = this.diffs.get(&buffer_id).cloned() { + diff_state.update(cx, |diff_state, cx| { + diff_state.clear_pending_index_edits_and_hunks(cx); + }); + } + cx.emit(GitStoreEvent::IndexWriteError(error)); + }) + .ok(); } - } + }) + .detach(); } fn local_worktree_git_repos_changed( @@ -2146,6 +2543,7 @@ impl GitStore { || update.new_work_directory_abs_path.as_ref() == Some(repo_abs_path) }) { repository.reload_buffer_diff_bases(cx); + cx.emit(RepositoryEvent::GitDirectoryChanged); } }); } @@ -2752,12 +3150,18 @@ impl GitStore { repository_handle.get_remotes(branch_name, is_push) }) .await??; + let remote_urls = repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.remote_urls() + }) + .await??; Ok(proto::GetRemotesResponse { remotes: remotes .into_iter() .map(|remotes| proto::get_remotes_response::Remote { name: remotes.name.to_string(), + url: remote_urls.get(remotes.name.as_ref()).cloned(), }) .collect::>(), }) @@ -3165,7 +3569,7 @@ impl GitStore { let branch = repository_handle .update(&mut cx, |repository_handle, _| { - repository_handle.default_branch(false) + repository_handle.default_branch(envelope.payload.include_remote_name) }) .await?? .map(Into::into); @@ -3450,7 +3854,7 @@ impl GitStore { .files .into_iter() .map(|file| proto::CommitFile { - path: file.path.to_proto(), + path: file.path.as_unix_str().to_owned(), old_text: file.old_text, new_text: file.new_text, is_binary: file.is_binary, @@ -3502,6 +3906,40 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_add_path_to_gitignore( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let repo_path = RepoPath::from_proto(&envelope.payload.path)?; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.add_path_to_gitignore(&repo_path, envelope.payload.is_dir) + }) + .await??; + Ok(proto::Ack {}) + } + + async fn handle_add_path_to_git_info_exclude( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let repo_path = RepoPath::from_proto(&envelope.payload.path)?; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.add_path_to_git_info_exclude(&repo_path, envelope.payload.is_dir) + }) + .await??; + Ok(proto::Ack {}) + } + async fn handle_open_commit_message_buffer( this: Entity, envelope: TypedEnvelope, @@ -3648,7 +4086,7 @@ impl GitStore { .entries .into_iter() .map(|(path, status)| proto::TreeDiffStatus { - path: path.as_ref().to_proto(), + path: path.as_ref().as_unix_str().to_owned(), status: match status { TreeDiffStatus::Added {} => proto::tree_diff_status::Status::Added.into(), TreeDiffStatus::Modified { .. } => { @@ -3962,6 +4400,7 @@ impl BufferGitState { hunk_staging_operation_count_as_of_write: 0, head_text: Default::default(), index_text: Default::default(), + pending_index_edits: Some(Vec::new()), oid_texts: Default::default(), head_text_buffer: WeakEntity::new_invalid(), index_text_buffer: WeakEntity::new_invalid(), @@ -3989,13 +4428,27 @@ impl BufferGitState { buffer } - fn get_or_create_index_text_buffer(&mut self, cx: &mut Context) -> Entity { + fn index_text_buffer(&self) -> Option> { + self.index_text_buffer.upgrade() + } + + fn get_or_create_index_text_buffer( + &mut self, + file: Option>, + cx: &mut Context, + ) -> Entity { if let Some(buffer) = self.index_text_buffer.upgrade() { + if let Some(file) = file { + buffer.update(cx, |buffer, cx| buffer.file_updated(file, cx)); + } return buffer; } let index_text = self.index_text.clone(); let buffer = cx.new(|cx| { let mut buffer = Buffer::local(index_text.as_deref().unwrap_or(""), cx); + if let Some(file) = file { + buffer.file_updated(file, cx); + } buffer.set_capability(Capability::ReadOnly, cx); buffer }); @@ -4066,6 +4519,11 @@ impl BufferGitState { self.staged_diff.as_ref().and_then(|(set, _)| set.upgrade()) } + fn staged_diff_and_index_text_buffer(&self) -> Option<(Entity, Entity)> { + let (diff, index_text_buffer) = self.staged_diff.as_ref()?; + Some((diff.upgrade()?, index_text_buffer.clone())) + } + fn uncommitted_diff(&self) -> Option> { self.uncommitted_diff.as_ref().and_then(|set| set.upgrade()) } @@ -4087,6 +4545,100 @@ impl BufferGitState { } } + fn remove_overlapping_pending_index_edits(&mut self, ranges: &[Range]) { + if let Some(edits) = &mut self.pending_index_edits { + edits.retain(|(existing, _)| { + ranges.iter().all(|footprint| { + existing.end < footprint.start || footprint.end < existing.start + }) + }); + } + } + + fn insert_pending_index_edits(&mut self, edits: Option, Arc)>>) { + match edits { + None => { + self.pending_index_edits = None; + } + Some(new_edits) => { + let mut edits = self.pending_index_edits.take().unwrap_or_default(); + for (range, replacement) in new_edits { + edits.retain(|(existing, _)| { + existing.end < range.start || range.end < existing.start + }); + let position = + edits.partition_point(|(existing, _)| existing.start < range.start); + edits.insert(position, (range, replacement)); + } + self.pending_index_edits = Some(edits); + } + } + } + + fn pending_index_text(&self, cx: &App) -> Option { + let index_text_buffer = self.index_text_buffer.upgrade()?; + let edits = self.pending_index_edits.as_ref()?; + #[cfg(debug_assertions)] + for window in edits.windows(2) { + debug_assert!(window[0].0.end <= window[1].0.start); + } + let mut index_text = index_text_buffer.read(cx).text_snapshot().as_rope().clone(); + for (old_range, replacement_text) in edits.iter().rev() { + index_text.replace(old_range.clone(), replacement_text); + } + Some(index_text) + } + + fn clear_pending_index_edits(&mut self) { + self.pending_index_edits = Some(Vec::new()); + } + + fn clear_pending_hunks(&mut self, cx: &mut Context) { + for diff in [ + self.uncommitted_diff(), + self.unstaged_diff(), + self.staged_diff(), + ] + .into_iter() + .flatten() + { + diff.update(cx, |diff, cx| diff.clear_pending_hunks(cx)); + } + } + + fn mark_whole_file_stage_or_unstage_pending( + &mut self, + stage: bool, + buffer_snapshot: &text::BufferSnapshot, + cx: &mut Context, + ) { + if let Some(uncommitted_diff) = self.uncommitted_diff() { + uncommitted_diff.update(cx, |uncommitted_diff, cx| { + uncommitted_diff.mark_all_hunks_pending(stage, buffer_snapshot, cx); + }); + } + + if stage { + if let Some(unstaged_diff) = self.unstaged_diff() { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.suppress_all_hunks_pending(buffer_snapshot, cx); + }); + } + } else if let Some(staged_diff) = self.staged_diff() + && let Some(index_text_buffer) = self.index_text_buffer() + { + let index_snapshot = index_text_buffer.read(cx).text_snapshot(); + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.suppress_all_hunks_pending(&index_snapshot, cx); + }); + } + } + + fn clear_pending_index_edits_and_hunks(&mut self, cx: &mut Context) { + self.clear_pending_index_edits(); + self.clear_pending_hunks(cx); + } + fn handle_base_texts_updated( &mut self, buffer: text::BufferSnapshot, @@ -4100,16 +4652,17 @@ impl BufferGitState { }; let diff_bases_change = match mode { - Mode::HeadOnly => DiffBasesChange::SetHead(message.committed_text), - Mode::IndexOnly => DiffBasesChange::SetIndex(message.staged_text), - Mode::IndexMatchesHead => DiffBasesChange::SetBoth(message.committed_text), - Mode::IndexAndHead => DiffBasesChange::SetEach { + Mode::HeadOnly => Some(DiffBasesChange::SetHead(message.committed_text)), + Mode::IndexOnly => Some(DiffBasesChange::SetIndex(message.staged_text)), + Mode::IndexMatchesHead => Some(DiffBasesChange::SetBoth(message.committed_text)), + Mode::IndexAndHead => Some(DiffBasesChange::SetEach { index: message.staged_text, head: message.committed_text, - }, + }), + Mode::Unchanged => None, }; - self.diff_bases_changed(buffer, Some(diff_bases_change), cx); + self.diff_bases_changed(buffer, diff_bases_change, cx); } pub fn wait_for_recalculation(&mut self) -> Option + use<>> { @@ -4414,7 +4967,9 @@ impl BufferGitState { return Ok(()); } - this.update(cx, |_, cx| { + this.update(cx, |this, cx| { + this.clear_pending_index_edits(); + if let (Some(staged_diff), Some(new_staged_diff)) = (staged_diff.as_ref(), new_staged_diff.clone()) { @@ -4433,7 +4988,7 @@ impl BufferGitState { head_text_buffer.fast_forward(edited_head_text, cx) }); } - diff.set_snapshot(new_staged_diff, cx) + diff.set_snapshot_with_secondary(new_staged_diff, None, true, cx) }); } @@ -4448,7 +5003,7 @@ impl BufferGitState { index_text_buffer.fast_forward(edited_index_text, cx) }); } - diff.set_snapshot(new_unstaged_diff, cx) + diff.set_snapshot_with_secondary(new_unstaged_diff, None, true, cx) })) } else { None @@ -4617,7 +5172,7 @@ impl RepositorySnapshot { .merge .merge_heads_by_conflicted_path .iter() - .map(|(repo_path, _)| repo_path.to_proto()) + .map(|(repo_path, _)| repo_path.as_unix_str().to_owned()) .collect(), merge_message: self.merge.message.as_ref().map(|msg| msg.to_string()), project_id, @@ -4673,13 +5228,13 @@ impl RepositorySnapshot { current_new_entry = new_statuses.next(); } Ordering::Greater => { - removed_statuses.push(old_entry.repo_path.to_proto()); + removed_statuses.push(old_entry.repo_path.as_unix_str().to_owned()); current_old_entry = old_statuses.next(); } } } (None, Some(old_entry)) => { - removed_statuses.push(old_entry.repo_path.to_proto()); + removed_statuses.push(old_entry.repo_path.as_unix_str().to_owned()); current_old_entry = old_statuses.next(); } (Some(new_entry), None) => { @@ -4704,7 +5259,7 @@ impl RepositorySnapshot { .merge .merge_heads_by_conflicted_path .iter() - .map(|(path, _)| path.to_proto()) + .map(|(path, _)| path.as_unix_str().to_owned()) .collect(), merge_message: self.merge.message.as_ref().map(|msg| msg.to_string()), project_id, @@ -5178,26 +5733,36 @@ impl Repository { let buffer_diff_base_changes = cx .background_spawn(async move { + let mut revisions = Vec::new(); + for (_, repo_path, is_symlink, current_index_text, current_head_text) in + &repo_diff_state_updates + { + if current_index_text.is_some() && !*is_symlink { + revisions.push(format!(":{}", repo_path.as_unix_str())); + } + if current_head_text.is_some() && !*is_symlink { + revisions.push(format!("HEAD:{}", repo_path.as_unix_str())); + } + } + + let mut loaded_revisions = backend + .load_revisions(revisions) + .await + .log_err() + .into_iter() + .flatten(); + let mut changes = Vec::new(); - for ( - buffer, - repo_path, - is_symlink, - current_index_text, - current_head_text, - ) in &repo_diff_state_updates + for (buffer, _, is_symlink, current_index_text, current_head_text) in + &repo_diff_state_updates { - let index_text = if current_index_text.is_some() && !*is_symlink { - backend.load_index_text(repo_path.clone()) - } else { - future::ready(None).boxed() - }; - let head_text = if current_head_text.is_some() && !*is_symlink { - backend.load_committed_text(repo_path.clone()) - } else { - future::ready(None).boxed() - }; - let (index_text, head_text) = future::join(index_text, head_text).await; + let index_text = (current_index_text.is_some() && !*is_symlink) + .then(|| loaded_revisions.next().flatten()) + .flatten(); + + let head_text = (current_head_text.is_some() && !*is_symlink) + .then(|| loaded_revisions.next().flatten()) + .flatten(); let change = match (current_index_text.as_ref(), current_head_text.as_ref()) { @@ -5255,21 +5820,23 @@ impl Repository { diff_state.update(cx, |diff_state, cx| { use proto::update_diff_bases::Mode; - if let Some((diff_bases_change, (client, project_id))) = - diff_bases_change.clone().zip(downstream_client) - { - let (staged_text, committed_text, mode) = match diff_bases_change { - DiffBasesChange::SetIndex(index) => { - (index, None, Mode::IndexOnly) - } - DiffBasesChange::SetHead(head) => (None, head, Mode::HeadOnly), - DiffBasesChange::SetEach { index, head } => { - (index, head, Mode::IndexAndHead) - } - DiffBasesChange::SetBoth(text) => { - (None, text, Mode::IndexMatchesHead) - } - }; + if let Some((client, project_id)) = downstream_client { + let (staged_text, committed_text, mode) = + match diff_bases_change.clone() { + Some(DiffBasesChange::SetIndex(index)) => { + (index, None, Mode::IndexOnly) + } + Some(DiffBasesChange::SetHead(head)) => { + (None, head, Mode::HeadOnly) + } + Some(DiffBasesChange::SetEach { index, head }) => { + (index, head, Mode::IndexAndHead) + } + Some(DiffBasesChange::SetBoth(text)) => { + (None, text, Mode::IndexMatchesHead) + } + None => (None, None, Mode::Unchanged), + }; client .send(proto::UpdateDiffBases { project_id: project_id.to_proto(), @@ -5547,7 +6114,7 @@ impl Repository { commit, paths: paths .into_iter() - .map(|p| p.to_proto()) + .map(|p| p.as_unix_str().to_owned()) .collect(), }) .await?; @@ -6465,33 +7032,15 @@ impl Repository { else { continue; }; - let Some(uncommitted_diff) = - diff_state.read(cx).uncommitted_diff.as_ref().and_then( - |uncommitted_diff| uncommitted_diff.upgrade(), - ) - else { - continue; - }; let buffer_snapshot = buffer.read(cx).text_snapshot(); - let file_exists = buffer - .read(cx) - .file() - .is_some_and(|file| file.disk_state().exists()); let hunk_staging_operation_count = diff_state.update(cx, |diff_state, cx| { - uncommitted_diff.update( - cx, - |uncommitted_diff, cx| { - uncommitted_diff - .stage_or_unstage_all_hunks( - stage, - &buffer_snapshot, - file_exists, - cx, - ); - }, - ); - + diff_state + .mark_whole_file_stage_or_unstage_pending( + stage, + &buffer_snapshot, + cx, + ); diff_state.hunk_staging_operation_count += 1; diff_state.hunk_staging_operation_count }); @@ -6527,7 +7076,9 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.to_proto()) + .map(|repo_path| { + repo_path.as_unix_str().to_owned() + }) .collect(), }) .await @@ -6540,7 +7091,9 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.to_proto()) + .map(|repo_path| { + repo_path.as_unix_str().to_owned() + }) .collect(), }) .await @@ -6558,14 +7111,8 @@ impl Repository { if result.is_ok() { diff_state.hunk_staging_operation_count_as_of_write = hunk_staging_operation_count; - } else if let Some(uncommitted_diff) = - &diff_state.uncommitted_diff - { - uncommitted_diff - .update(cx, |uncommitted_diff, cx| { - uncommitted_diff.clear_pending_hunks(cx); - }) - .ok(); + } else { + diff_state.clear_pending_hunks(cx); } }) .ok(); @@ -6677,7 +7224,7 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.to_proto()) + .map(|repo_path| repo_path.as_unix_str().to_owned()) .collect(), }) .await?; @@ -6764,8 +7311,10 @@ impl Repository { repo_path: &RepoPath, is_dir: bool, ) -> oneshot::Receiver> { + let id = self.id; let work_dir = self.snapshot.work_directory_abs_path.clone(); - let path_display = repo_path.as_ref().display(PathStyle::Posix); + let path_display = repo_path.as_ref().display(PathStyle::Unix); + let path = repo_path.as_unix_str().to_owned(); let file_path_str = if is_dir { format!("{}/", path_display) } else { @@ -6785,9 +7334,18 @@ impl Repository { ) .await } - RepositoryState::Remote(_) => Err(anyhow::anyhow!( - "Cannot modify .gitignore on remote repository" - )), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitAddPathToGitignore { + project_id: project_id.0, + repository_id: id.to_proto(), + path, + is_dir, + }) + .await + .context("sending add path to .gitignore request")?; + Ok(()) + } } }, ) @@ -6798,8 +7356,10 @@ impl Repository { repo_path: &RepoPath, is_dir: bool, ) -> oneshot::Receiver> { + let id = self.id; let repository_dir = self.snapshot.repository_dir_abs_path.clone(); - let path_display = repo_path.as_ref().display(PathStyle::Posix); + let path_display = repo_path.as_ref().display(PathStyle::Unix); + let path = repo_path.as_unix_str().to_owned(); let file_path_str = if is_dir { format!("{}/", path_display) } else { @@ -6819,9 +7379,18 @@ impl Repository { ) .await } - RepositoryState::Remote(_) => Err(anyhow::anyhow!( - "Cannot modify .git/info/exclude on remote repository" - )), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitAddPathToGitInfoExclude { + project_id: project_id.0, + repository_id: id.to_proto(), + path, + is_dir, + }) + .await + .context("sending add path to .git/info/exclude request")?; + Ok(()) + } } }, ) @@ -6883,6 +7452,11 @@ impl Repository { }) } + // Kept for wire compatibility: older remote clients run the pre-commit hook explicitly + // via `proto::RunGitHook` before committing. New code lets `git commit` run hooks itself. + // + // TODO: remove together with `proto::RunGitHook` once all supported peers commit without + // sending it (see the deprecation note on the message in git.proto). pub fn run_hook(&mut self, hook: RunHook, _cx: &mut App) -> oneshot::Receiver> { let id = self.id; self.send_job( @@ -6917,20 +7491,16 @@ impl Repository { name_and_email: Option<(SharedString, SharedString)>, options: CommitOptions, askpass: AskPassDelegate, - cx: &mut App, + _cx: &mut App, ) -> oneshot::Receiver> { let id = self.id; let askpass_delegates = self.askpass_delegates.clone(); let askpass_id = util::post_inc(&mut self.latest_askpass_id); - let rx = self.run_hook(RunHook::PreCommit, cx); - self.send_job( "commit", Some("git commit".into()), move |git_repo, _cx| async move { - rx.await??; - match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -7242,7 +7812,7 @@ impl Repository { .request(proto::SetIndexText { project_id: project_id.0, repository_id: id.to_proto(), - path: path.to_proto(), + path: path.as_unix_str().to_owned(), text: content, }) .await?; @@ -7385,6 +7955,33 @@ impl Repository { }) } + pub fn remote_urls(&mut self) -> oneshot::Receiver>> { + let id = self.id; + self.send_job("remote_urls", None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + Ok(backend.remote_urls().await) + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GetRemotes { + project_id: project_id.0, + repository_id: id.to_proto(), + branch_name: None, + is_push: false, + }) + .await?; + + Ok(response + .remotes + .into_iter() + .filter_map(|remote| Some((remote.name, remote.url?))) + .collect()) + } + } + }) + } + pub fn branches(&mut self) -> oneshot::Receiver> { let id = self.id; self.send_job("branches", None, move |repo, _| async move { @@ -7869,6 +8466,7 @@ impl Repository { .request(proto::GetDefaultBranch { project_id: project_id.0, repository_id: id.to_proto(), + include_remote_name, }) .await?; @@ -7925,7 +8523,7 @@ impl Repository { }; Some(( RepoPath::from_rel_path( - &RelPath::from_proto(&entry.path).log_err()?, + RelPath::from_unix_str(&entry.path).log_err()?, ), status, )) @@ -8254,7 +8852,7 @@ impl Repository { .into_iter() .filter_map(|path| { Some(sum_tree::Edit::Remove(PathKey( - RelPath::from_proto(&path).log_err()?, + RelPath::from_unix_str(&path).log_err()?.into(), ))) }) .chain( @@ -8526,8 +9124,13 @@ impl Repository { let rx = self.send_job("load_committed_text", None, move |state, _| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - let committed_text = backend.load_committed_text(repo_path.clone()).await; - let staged_text = backend.load_index_text(repo_path).await; + let revisions = vec![ + format!("HEAD:{}", repo_path.as_unix_str()), + format!(":{}", repo_path.as_unix_str()), + ]; + let mut loaded_revisions = backend.load_revisions(revisions).await?.into_iter(); + let committed_text = loaded_revisions.next().flatten(); + let staged_text = loaded_revisions.next().flatten(); let diff_bases_change = if committed_text == staged_text { DiffBasesChange::SetBoth(committed_text) } else { @@ -8647,20 +9250,29 @@ impl Repository { let changed_paths_vec = changed_paths.iter().cloned().collect::>(); let status_task = backend.status(&changed_paths_vec); - let diff_stat_future = if has_head { - backend.diff_stat(&changed_paths_vec) - } else { - future::ready(Ok(status::GitDiffStat { - entries: Arc::default(), - })) - .boxed() + let diff_stat_future = |diff| { + if has_head { + backend.diff_stat(diff, &changed_paths_vec) + } else { + future::ready(Ok(status::GitDiffStat::default())).boxed() + } }; - let (statuses, diff_stats) = - futures::future::try_join(status_task, diff_stat_future).await?; + let (statuses, diff_stats, staged_diff_stats, unstaged_diff_stats) = + futures::future::try_join4( + status_task, + diff_stat_future(DiffStatType::HeadToWorktree), + diff_stat_future(DiffStatType::HeadToIndex), + diff_stat_future(DiffStatType::IndexToWorktree), + ) + .await?; let diff_stats: HashMap = HashMap::from_iter(diff_stats.entries.into_iter().cloned()); + let staged_diff_stats: HashMap = + HashMap::from_iter(staged_diff_stats.entries.into_iter().cloned()); + let unstaged_diff_stats: HashMap = + HashMap::from_iter(unstaged_diff_stats.entries.into_iter().cloned()); let mut changed_path_statuses = Vec::new(); let prev_statuses = prev_snapshot.statuses_by_path.clone(); @@ -8691,10 +9303,17 @@ impl Repository { for (repo_path, status) in &*statuses.entries { let current_diff_stat = diff_stats.get(repo_path).copied(); + let current_staged_diff_stat = + staged_diff_stats.get(repo_path).copied(); + let current_unstaged_diff_stat = + unstaged_diff_stats.get(repo_path).copied(); if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left) && cursor.item().is_some_and(|entry| { - entry.status == *status && entry.diff_stat == current_diff_stat + entry.status == *status + && entry.diff_stat == current_diff_stat + && entry.staged_diff_stat == current_staged_diff_stat + && entry.unstaged_diff_stat == current_unstaged_diff_stat }) { continue; @@ -8704,6 +9323,8 @@ impl Repository { repo_path: repo_path.clone(), status: *status, diff_stat: current_diff_stat, + staged_diff_stat: current_staged_diff_stat, + unstaged_diff_stat: current_unstaged_diff_stat, })); } anyhow::Ok(changed_path_statuses) @@ -8840,7 +9461,7 @@ fn format_job_key(key: &GitJobKey) -> SharedString { .iter() .map(|p| { let rel: &RelPath = p; - format!("{}", AsRef::::as_ref(rel).display()) + rel.display(PathStyle::local()) }) .collect(); format!("WriteIndex({})", paths_str.join(", ")).into() @@ -8919,7 +9540,7 @@ pub fn worktrees_directory_for_repo( let resolved = if path_style.is_posix() { joined } else { - util::normalize_path(&joined) + path::normalize_path(&joined) }; let resolved = if resolved.starts_with(repository_anchor_path) { resolved @@ -9163,7 +9784,11 @@ fn deserialize_blame_buffer_response( .filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message))) .collect::>(); - Some(Blame { entries, messages }) + Some(Blame { + entries, + messages, + tag_names: Default::default(), + }) } fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource { @@ -9172,7 +9797,9 @@ fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource { LogSource::All => proto::git_log_source::Source::All(proto::GitLogSourceAll {}), LogSource::Branch(branch) => proto::git_log_source::Source::Branch(branch.to_string()), LogSource::Sha(sha) => proto::git_log_source::Source::Sha(sha.to_string()), - LogSource::Path(path) => proto::git_log_source::Source::Path(path.to_proto()), + LogSource::Path(path) => { + proto::git_log_source::Source::Path(path.as_unix_str().to_owned()) + } }), } } @@ -9394,7 +10021,20 @@ async fn append_pattern_to_ignore_file( file_path: PathBuf, pattern: String, ) -> Result<()> { - let existing_content = fs.load(&file_path).await.unwrap_or_default(); + let existing_content = match fs.load(&file_path).await { + Ok(content) => content, + Err(error) + if error + .root_cause() + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + String::new() + } + Err(error) => { + return Err(error).with_context(|| format!("loading {}", file_path.display())); + } + }; if existing_content.lines().any(|line| line.trim() == pattern) { return Ok(()); @@ -9418,6 +10058,11 @@ async fn append_pattern_to_ignore_file( #[cfg(any(test, feature = "test-support"))] impl Repository { + pub fn set_branch_list_for_test(&mut self, branches: Vec, cx: &mut Context) { + self.snapshot.branch_list = branches.into(); + cx.emit(RepositoryEvent::BranchListChanged); + } + pub fn loaded_commit_data_for_test(&self) -> HashMap { self.commit_data .iter() @@ -9571,11 +10216,9 @@ mod tests { fn test_new_worktree_path_uses_posix_style_for_remote_paths() { let work_dir = Path::new("/home/user/dev/lsp-tests"); let directory = - worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).unwrap(); - let directory = PathStyle::Posix - .join_path(&directory, "nimble-sky") - .unwrap(); - let path = PathStyle::Posix.join_path(&directory, "lsp-tests").unwrap(); + worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Unix).unwrap(); + let directory = PathStyle::Unix.join_path(&directory, "nimble-sky").unwrap(); + let path = PathStyle::Unix.join_path(&directory, "lsp-tests").unwrap(); assert_eq!( path, @@ -10038,14 +10681,23 @@ async fn compute_snapshot( .unwrap_or_default() } }; - let diff_stat_future = { + let diff_stats_future = { let snapshot = snapshot.clone(); let backend = backend.clone(); async move { if snapshot.head_commit.is_some() { - backend.diff_stat(&[]).await.log_err().unwrap_or_default() + futures::future::join3( + backend.diff_stat(DiffStatType::HeadToWorktree, &[]), + backend.diff_stat(DiffStatType::HeadToIndex, &[]), + backend.diff_stat(DiffStatType::IndexToWorktree, &[]), + ) + .await } else { - Default::default() + ( + Ok(status::GitDiffStat::default()), + Ok(status::GitDiffStat::default()), + Ok(status::GitDiffStat::default()), + ) } } }; @@ -10055,11 +10707,25 @@ async fn compute_snapshot( }; let (statuses, diff_stats, stash_entries) = - futures::future::join3(statuses_future, diff_stat_future, stash_entries_future).await; + futures::future::join3(statuses_future, diff_stats_future, stash_entries_future).await; + let (diff_stats, staged_diff_stats, unstaged_diff_stats) = diff_stats; + let diff_stats = diff_stats.log_err().unwrap_or_default(); + let staged_diff_stats = staged_diff_stats.log_err().unwrap_or_default(); + let unstaged_diff_stats = unstaged_diff_stats.log_err().unwrap_or_default(); log::debug!("fetched statuses, diff stats, stash entries"); let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); + let staged_diff_stat_map: HashMap<&RepoPath, DiffStat> = staged_diff_stats + .entries + .iter() + .map(|(p, s)| (p, *s)) + .collect(); + let unstaged_diff_stat_map: HashMap<&RepoPath, DiffStat> = unstaged_diff_stats + .entries + .iter() + .map(|(p, s)| (p, *s)) + .collect(); let mut conflicted_paths = Vec::new(); let statuses_by_path = SumTree::from_iter( statuses.entries.iter().map(|(repo_path, status)| { @@ -10070,6 +10736,8 @@ async fn compute_snapshot( repo_path: repo_path.clone(), status: *status, diff_stat: diff_stat_map.get(repo_path).copied(), + staged_diff_stat: staged_diff_stat_map.get(repo_path).copied(), + unstaged_diff_stat: unstaged_diff_stat_map.get(repo_path).copied(), } }), (), diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/diff_buffer_list.rs similarity index 76% rename from crates/project/src/git_store/branch_diff.rs rename to crates/project/src/git_store/diff_buffer_list.rs index 67aa198945d239..2f1586d3e04d1b 100644 --- a/crates/project/src/git_store/branch_diff.rs +++ b/crates/project/src/git_store/diff_buffer_list.rs @@ -24,6 +24,8 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DiffBase { Head, + Index, + Staged, Merge { base_ref: SharedString }, } @@ -33,7 +35,7 @@ impl DiffBase { } } -pub struct BranchDiff { +pub struct DiffBufferList { diff_base: DiffBase, repo: Option>, project: Entity, @@ -52,9 +54,9 @@ pub enum BranchDiffEvent { DiffBaseChanged, } -impl EventEmitter for BranchDiff {} +impl EventEmitter for DiffBufferList {} -impl BranchDiff { +impl DiffBufferList { pub fn new( source: DiffBase, project: Entity, @@ -350,8 +352,13 @@ impl BranchDiff { .as_ref() .and_then(|t| t.entries.get(&item.repo_path)) .cloned(); - let Some(status) = self.merge_statuses(Some(item.status), branch_diff.as_ref()) - else { + let Some(status) = (match self.diff_base { + DiffBase::Head | DiffBase::Merge { .. } => { + self.merge_statuses(Some(item.status), branch_diff.as_ref()) + } + DiffBase::Index => item.status.staging().has_unstaged().then_some(item.status), + DiffBase::Staged => item.status.staging().has_staged().then_some(item.status), + }) else { continue; }; if !status.has_changes() { @@ -363,7 +370,13 @@ impl BranchDiff { else { continue; }; - let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + branch_diff, + project_path, + repo.clone(), + cx, + ); output.push(DiffBuffer { repo_path: item.repo_path.clone(), @@ -383,8 +396,13 @@ impl BranchDiff { let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else { continue; }; - let task = - Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + Some(branch_diff.clone()), + project_path, + repo.clone(), + cx, + ); let file_status = diff_status_to_file_status(branch_diff); @@ -400,44 +418,87 @@ impl BranchDiff { #[instrument(skip_all)] fn load_buffer( + diff_base: DiffBase, branch_diff: Option, project_path: crate::ProjectPath, repo: Entity, cx: &Context<'_, Project>, - ) -> Task, Entity, Entity)>> { + ) -> Task> { let task = cx.spawn(async move |project, cx| { let buffer = project .update(cx, |project, cx| project.open_buffer(project_path, cx))? .await?; - let changes = if let Some(entry) = branch_diff { - let oid = match entry { - git::status::TreeDiffStatus::Added { .. } => None, - git::status::TreeDiffStatus::Modified { old, .. } - | git::status::TreeDiffStatus::Deleted { old } => Some(old), - }; - project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_diff_since(oid, buffer.clone(), repo, cx) - }) - })? - .await? + let main_buffer = buffer.clone(); + let load_conflict_set = diff_base != DiffBase::Staged; + let (display_buffer, changes) = match diff_base { + DiffBase::Head => { + let diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await?; + (buffer, diff) + } + DiffBase::Index => { + let diff = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + })? + .await?; + (buffer, diff) + } + DiffBase::Staged => { + let (diff, index_buffer) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + })? + .await?; + (index_buffer, diff) + } + DiffBase::Merge { .. } => { + let diff = if let Some(entry) = branch_diff { + let oid = match entry { + git::status::TreeDiffStatus::Added { .. } => None, + git::status::TreeDiffStatus::Modified { old, .. } + | git::status::TreeDiffStatus::Deleted { old } => Some(old), + }; + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_diff_since(oid, buffer.clone(), repo, cx) + }) + })? + .await? + } else { + project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await? + }; + (buffer, diff) + } + }; + let conflict_set = if load_conflict_set { + Some( + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_conflict_set(main_buffer.clone(), cx) + }) + })? + .await, + ) } else { - project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? - .await? + None }; - let conflict_set = project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_conflict_set(buffer.clone(), cx) - }) - })? - .await; - Ok((buffer, changes, conflict_set)) + Ok(LoadedDiffBuffer { + display_buffer, + main_buffer, + diff: changes, + conflict_set, + }) }); task } @@ -461,9 +522,17 @@ fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> File file_status } +#[derive(Debug)] +pub struct LoadedDiffBuffer { + pub display_buffer: Entity, + pub main_buffer: Entity, + pub diff: Entity, + pub conflict_set: Option>, +} + #[derive(Debug)] pub struct DiffBuffer { pub repo_path: RepoPath, pub file_status: FileStatus, - pub load: Task, Entity, Entity)>>, + pub load: Task>, } diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 0b6dcfa0078588..667440a437ccb9 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -698,7 +698,7 @@ impl ImageStoreImpl for Entity { .request(rpc::proto::OpenImageByPath { project_id, worktree_id, - path: path.to_proto(), + path: path.as_unix_str().to_owned(), }) .await?; diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 2f05aba49b5f30..7ed33f280d8777 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -4,7 +4,7 @@ use crate::{ CodeAction, CompletionSource, CoreCompletion, CoreCompletionResponse, DocumentColor, DocumentHighlight, DocumentSymbol, Hover, HoverBlock, HoverBlockKind, InlayHint, InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location, - LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, + LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, ProjectPath, ProjectTransaction, PulledDiagnostics, ResolveState, lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore}, }; @@ -19,7 +19,7 @@ use language::{ Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped, language_settings::{InlayHintKind, LanguageSettings}, - point_from_lsp, point_to_lsp, + lsp_to_symbol_kind, point_from_lsp, point_to_lsp, proto::{ deserialize_anchor, deserialize_anchor_range, deserialize_version, serialize_anchor, serialize_anchor_range, serialize_version, @@ -35,10 +35,9 @@ use lsp::{ use serde_json::Value; use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature}; -use std::{ - cmp::Reverse, collections::hash_map, mem, ops::Range, path::Path, str::FromStr, sync::Arc, -}; +use std::{cmp::Reverse, collections::hash_map, ops::Range, path::Path, str::FromStr, sync::Arc}; use text::{BufferId, LineEnding}; +use util::rel_path::RelPath; use util::{ResultExt as _, debug_panic}; pub use signature_help::SignatureHelp; @@ -189,7 +188,17 @@ pub(crate) struct PerformRename { #[derive(Debug, Clone, Copy)] pub struct GetDefinitions { pub position: PointUtf16, - pub workspace_only: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EditPredictionDefinition { + pub path: ProjectPath, + pub range: Range>, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct GetEditPredictionDefinitions { + pub position: PointUtf16, } #[derive(Debug, Clone, Copy)] @@ -200,7 +209,11 @@ pub(crate) struct GetDeclarations { #[derive(Debug, Clone, Copy)] pub(crate) struct GetTypeDefinitions { pub position: PointUtf16, - pub workspace_only: bool, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct GetEditPredictionTypeDefinitions { + pub position: PointUtf16, } #[derive(Debug, Clone, Copy)] @@ -694,15 +707,7 @@ impl LspCommand for GetDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp( - message, - lsp_store, - buffer, - server_id, - self.workspace_only, - cx, - ) - .await + location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDefinition { @@ -713,7 +718,6 @@ impl LspCommand for GetDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), - workspace_only: self.workspace_only, } } @@ -734,7 +738,6 @@ impl LspCommand for GetDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), - workspace_only: message.workspace_only, }) } @@ -764,6 +767,106 @@ impl LspCommand for GetDefinitions { } } +#[async_trait(?Send)] +impl LspCommand for GetEditPredictionDefinitions { + type Response = Vec; + type LspRequest = lsp::request::GotoDefinition; + type ProtoRequest = proto::GetEditPredictionDefinition; + + fn display_name(&self) -> &str { + "Get edit prediction definition" + } + + fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + capabilities + .server_capabilities + .definition_provider + .is_some_and(|capability| match capability { + OneOf::Left(supported) => supported, + OneOf::Right(_options) => true, + }) + } + + fn to_lsp( + &self, + path: &Path, + _: &Buffer, + _: &Arc, + _: &App, + ) -> Result { + Ok(lsp::GotoDefinitionParams { + text_document_position_params: make_lsp_text_document_position(path, self.position)?, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option, + lsp_store: Entity, + _: Entity, + _: LanguageServerId, + cx: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_lsp(message, lsp_store, cx) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetEditPredictionDefinition { + proto::GetEditPredictionDefinition { + project_id, + buffer_id: buffer.remote_id().into(), + position: Some(language::proto::serialize_anchor( + &buffer.anchor_before(self.position), + )), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + message: proto::GetEditPredictionDefinition, + _: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + Ok(Self { + position: edit_prediction_position_from_proto( + message.position, + message.version, + buffer, + &mut cx, + ) + .await?, + }) + } + + fn response_to_proto( + response: Vec, + _: &mut LspStore, + _: PeerId, + _: &clock::Global, + _: &mut App, + ) -> proto::GetEditPredictionDefinitionResponse { + proto::GetEditPredictionDefinitionResponse { + definitions: edit_prediction_definitions_to_proto(response), + } + } + + async fn response_from_proto( + self, + message: proto::GetEditPredictionDefinitionResponse, + _: Entity, + _: Entity, + _: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_proto(message.definitions) + } + + fn buffer_id_from_proto(message: &proto::GetEditPredictionDefinition) -> Result { + BufferId::new(message.buffer_id) + } +} + #[async_trait(?Send)] impl LspCommand for GetDeclarations { type Response = Vec; @@ -807,7 +910,7 @@ impl LspCommand for GetDeclarations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDeclaration { @@ -909,7 +1012,7 @@ impl LspCommand for GetImplementations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetImplementation { @@ -1008,7 +1111,7 @@ impl LspCommand for GetTypeDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, project, buffer, server_id, self.workspace_only, cx).await + location_links_from_lsp(message, project, buffer, server_id, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetTypeDefinition { @@ -1019,7 +1122,6 @@ impl LspCommand for GetTypeDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), - workspace_only: self.workspace_only, } } @@ -1040,7 +1142,6 @@ impl LspCommand for GetTypeDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), - workspace_only: message.workspace_only, }) } @@ -1070,6 +1171,103 @@ impl LspCommand for GetTypeDefinitions { } } +#[async_trait(?Send)] +impl LspCommand for GetEditPredictionTypeDefinitions { + type Response = Vec; + type LspRequest = lsp::request::GotoTypeDefinition; + type ProtoRequest = proto::GetEditPredictionTypeDefinition; + + fn display_name(&self) -> &str { + "Get edit prediction type definition" + } + + fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + !matches!( + &capabilities.server_capabilities.type_definition_provider, + None | Some(lsp::TypeDefinitionProviderCapability::Simple(false)) + ) + } + + fn to_lsp( + &self, + path: &Path, + _: &Buffer, + _: &Arc, + _: &App, + ) -> Result { + Ok(lsp::GotoTypeDefinitionParams { + text_document_position_params: make_lsp_text_document_position(path, self.position)?, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option, + lsp_store: Entity, + _: Entity, + _: LanguageServerId, + cx: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_lsp(message, lsp_store, cx) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetEditPredictionTypeDefinition { + proto::GetEditPredictionTypeDefinition { + project_id, + buffer_id: buffer.remote_id().into(), + position: Some(language::proto::serialize_anchor( + &buffer.anchor_before(self.position), + )), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + message: proto::GetEditPredictionTypeDefinition, + _: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + Ok(Self { + position: edit_prediction_position_from_proto( + message.position, + message.version, + buffer, + &mut cx, + ) + .await?, + }) + } + + fn response_to_proto( + response: Vec, + _: &mut LspStore, + _: PeerId, + _: &clock::Global, + _: &mut App, + ) -> proto::GetEditPredictionTypeDefinitionResponse { + proto::GetEditPredictionTypeDefinitionResponse { + definitions: edit_prediction_definitions_to_proto(response), + } + } + + async fn response_from_proto( + self, + message: proto::GetEditPredictionTypeDefinitionResponse, + _: Entity, + _: Entity, + _: AsyncApp, + ) -> Result> { + edit_prediction_definitions_from_proto(message.definitions) + } + + fn buffer_id_from_proto(message: &proto::GetEditPredictionTypeDefinition) -> Result { + BufferId::new(message.buffer_id) + } +} + fn language_server_for_buffer( lsp_store: &Entity, buffer: &Entity, @@ -1165,57 +1363,13 @@ pub async fn location_links_from_lsp( lsp_store: Entity, buffer: Entity, server_id: LanguageServerId, - workspace_only: bool, mut cx: AsyncApp, ) -> Result> { - let message = match message { - Some(message) => message, - None => return Ok(Vec::new()), - }; - - let mut unresolved_links = Vec::new(); - match message { - lsp::GotoDefinitionResponse::Scalar(loc) => { - unresolved_links.push((None, loc.uri, loc.range)); - } - - lsp::GotoDefinitionResponse::Array(locs) => { - unresolved_links.extend(locs.into_iter().map(|l| (None, l.uri, l.range))); - } - - lsp::GotoDefinitionResponse::Link(links) => { - unresolved_links.extend(links.into_iter().map(|l| { - ( - l.origin_selection_range, - l.target_uri, - l.target_selection_range, - ) - })); - } - } + let unresolved_links = definition_locations_from_lsp(message); let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; let mut definitions = Vec::new(); for (origin_range, target_uri, target_range) in unresolved_links { - if workspace_only - && !lsp_store.update(&mut cx, |this, cx| { - use util::paths::UrlExt as _; - let worktree_store = this.worktree_store().read(cx); - let path_style = worktree_store.path_style(); - let Ok(abs_path) = target_uri.clone().to_file_path_ext(path_style) else { - return false; - }; - worktree_store - .find_worktree(&abs_path, cx) - .is_some_and(|(worktree, _)| { - let worktree = worktree.read(cx); - worktree.is_visible() && !worktree.is_single_file() - }) - }) - { - continue; - } - let target_buffer_handle = lsp_store .update(&mut cx, |this, cx| { this.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) @@ -1256,6 +1410,94 @@ pub async fn location_links_from_lsp( Ok(definitions) } +fn definition_locations_from_lsp( + message: Option, +) -> Vec<(Option, lsp::Uri, lsp::Range)> { + let Some(message) = message else { + return Vec::new(); + }; + + let mut locations = Vec::new(); + match message { + lsp::GotoDefinitionResponse::Scalar(location) => { + locations.push((None, location.uri, location.range)); + } + + lsp::GotoDefinitionResponse::Array(locations_from_lsp) => { + locations.extend( + locations_from_lsp + .into_iter() + .map(|location| (None, location.uri, location.range)), + ); + } + + lsp::GotoDefinitionResponse::Link(links) => { + locations.extend(links.into_iter().map(|link| { + ( + link.origin_selection_range, + link.target_uri, + link.target_selection_range, + ) + })); + } + } + locations +} + +fn edit_prediction_definitions_from_lsp( + message: Option, + lsp_store: Entity, + mut cx: AsyncApp, +) -> Result> { + let unresolved_locations = definition_locations_from_lsp(message); + lsp_store.update(&mut cx, |lsp_store, cx| { + use util::paths::UrlExt as _; + let mut definitions = Vec::new(); + let worktree_store = lsp_store.worktree_store().read(cx); + let path_style = worktree_store.path_style(); + + for (_, uri, range) in unresolved_locations { + let Ok(abs_path) = uri.to_file_path_ext(path_style) else { + continue; + }; + let Some((worktree, relative_path)) = worktree_store.find_worktree(&abs_path, cx) + else { + continue; + }; + let worktree = worktree.read(cx); + if !worktree.is_visible() || worktree.is_single_file() { + continue; + } + definitions.push(EditPredictionDefinition { + path: ProjectPath { + worktree_id: worktree.id(), + path: relative_path, + }, + range: point_from_lsp(range.start)..point_from_lsp(range.end), + }); + } + + Ok(definitions) + }) +} + +async fn edit_prediction_position_from_proto( + position: Option, + version: Vec, + buffer: Entity, + cx: &mut AsyncApp, +) -> Result { + let position = position + .and_then(deserialize_anchor) + .context("invalid position")?; + buffer + .update(cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&version)) + }) + .await?; + Ok(buffer.read_with(cx, |buffer, _| position.to_point_utf16(buffer))) +} + pub async fn location_link_from_lsp( link: lsp::LocationLink, lsp_store: &Entity, @@ -1363,6 +1605,48 @@ pub fn location_link_to_proto( } } +fn edit_prediction_definitions_to_proto( + definitions: Vec, +) -> Vec { + definitions + .into_iter() + .map(|definition| proto::EditPredictionDefinition { + worktree_id: definition.path.worktree_id.to_proto(), + path: definition.path.path.as_ref().as_unix_str().to_owned(), + start: Some(proto::PointUtf16 { + row: definition.range.start.0.row, + column: definition.range.start.0.column, + }), + end: Some(proto::PointUtf16 { + row: definition.range.end.0.row, + column: definition.range.end.0.column, + }), + }) + .collect() +} + +fn edit_prediction_definitions_from_proto( + definitions: Vec, +) -> Result> { + definitions + .into_iter() + .map(|definition| { + let start = definition.start.context("missing definition start")?; + let end = definition.end.context("missing definition end")?; + Ok(EditPredictionDefinition { + path: ProjectPath { + worktree_id: worktree::WorktreeId::from_proto(definition.worktree_id), + path: RelPath::from_unix_str(&definition.path) + .context("invalid path")? + .into(), + }, + range: Unclipped(PointUtf16::new(start.row, start.column)) + ..Unclipped(PointUtf16::new(end.row, end.column)), + }) + }) + .collect() +} + #[async_trait(?Send)] impl LspCommand for GetReferences { type Response = Vec; @@ -1749,7 +2033,7 @@ impl LspCommand for GetDocumentSymbols { .into_iter() .map(|lsp_symbol| DocumentSymbol { name: lsp_symbol.name, - kind: lsp_symbol.kind, + kind: lsp_to_symbol_kind(lsp_symbol.kind), range: range_from_lsp(lsp_symbol.location.range), selection_range: range_from_lsp(lsp_symbol.location.range), children: Vec::new(), @@ -1759,7 +2043,7 @@ impl LspCommand for GetDocumentSymbols { fn convert_symbol(lsp_symbol: lsp::DocumentSymbol) -> DocumentSymbol { DocumentSymbol { name: lsp_symbol.name, - kind: lsp_symbol.kind, + kind: lsp_to_symbol_kind(lsp_symbol.kind), range: range_from_lsp(lsp_symbol.range), selection_range: range_from_lsp(lsp_symbol.selection_range), children: lsp_symbol @@ -1811,7 +2095,7 @@ impl LspCommand for GetDocumentSymbols { fn convert_symbol_to_proto(symbol: DocumentSymbol) -> proto::DocumentSymbol { proto::DocumentSymbol { name: symbol.name.clone(), - kind: unsafe { mem::transmute::(symbol.kind) }, + kind: symbol.kind as i32, start: Some(proto::PointUtf16 { row: symbol.range.start.0.row, column: symbol.range.start.0.column, @@ -1854,8 +2138,7 @@ impl LspCommand for GetDocumentSymbols { fn deserialize_symbol_with_children( serialized_symbol: proto::DocumentSymbol, ) -> Result { - let kind = - unsafe { mem::transmute::(serialized_symbol.kind) }; + let kind = language::SymbolKind::from_proto(serialized_symbol.kind); let start = serialized_symbol.start.context("invalid start")?; let end = serialized_symbol.end.context("invalid end")?; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index feb1936c47a99f..fee9666ec78470 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -61,7 +61,7 @@ use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; use futures::{ AsyncWriteExt, Future, FutureExt, StreamExt, channel::oneshot, - future::{Either, Shared, join_all, pending, select}, + future::{Either, Shared, join_all, select}, select, select_biased, stream::FuturesUnordered, }; @@ -83,7 +83,7 @@ use language::{ AllLanguageSettings, FormatOnSave, Formatter, LanguageSettings, LineEndingSetting, all_language_settings, }, - modeline, point_to_lsp, + lsp_to_symbol_kind, modeline, point_to_lsp, proto::{ deserialize_anchor, deserialize_anchor_range, deserialize_version, serialize_anchor, serialize_anchor_range, serialize_version, @@ -98,8 +98,8 @@ use lsp::{ FileOperationRegistrationOptions, FileRename, FileSystemWatcher, LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerId, LanguageServerName, LanguageServerSelector, LspRequestFuture, MessageActionItem, MessageType, OneOf, - RenameFilesParams, SymbolKind, TextDocumentSyncSaveOptions, TextEdit, Uri, WillRenameFiles, - WorkDoneProgressCancelParams, WorkspaceFolder, notification::DidRenameFiles, + RenameFilesParams, TextDocumentSyncSaveOptions, TextEdit, Uri, WillRenameFiles, + WorkDoneProgressCancelParams, notification::DidRenameFiles, }; use node_runtime::read_package_installed_version; use parking_lot::Mutex; @@ -123,7 +123,6 @@ use std::{ collections::{VecDeque, hash_map}, convert::TryInto, ffi::OsStr, - future::ready, iter, mem, ops::{ControlFlow, Range}, path::{self, Path, PathBuf}, @@ -985,10 +984,7 @@ impl LocalLspStore { let root = server.workspace_folders(); Ok(Some( root.into_iter() - .map(|uri| WorkspaceFolder { - uri, - name: Default::default(), - }) + .map(lsp::workspace_folder_for_uri) .collect(), )) } @@ -1548,6 +1544,7 @@ impl LocalLspStore { }); let mut project_transaction = ProjectTransaction::default(); + let mut format_error = None; for buffer in &buffers { zlog::debug!( @@ -1601,10 +1598,16 @@ impl LocalLspStore { .insert(cx.entity(), formatting_transaction); }); - result?; + // Keep formatting the remaining buffers on failure, surfacing the first error. + if let Err(error) = result { + format_error.get_or_insert(error); + } } - Ok(project_transaction) + match format_error { + Some(error) => Err(error), + None => Ok(project_transaction), + } } async fn format_buffer_locally( @@ -1635,12 +1638,36 @@ impl LocalLspStore { .handle .read_with(cx, |buffer, _| buffer.max_point().row > 0); + // When formatting a selection, only the rows it spans may be touched. + let selection_row_ranges = buffer.ranges.as_ref().map(|ranges| { + buffer.handle.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + ranges + .iter() + .map(|range| { + let start = range.start.to_point(&snapshot); + let end = range.end.to_point(&snapshot); + // A selection ending at column 0 of a row only includes that row's + // preceding line break, not its content, so it shouldn't be trimmed. + let end_row = if end.column == 0 && end.row > start.row { + end.row + } else { + end.row + 1 + }; + start.row..end_row + }) + .collect::>() + }) + }); + // handle whitespace formatting if settings.remove_trailing_whitespace_on_save { zlog::trace!(logger => "removing trailing whitespace"); let diff = buffer .handle - .read_with(cx, |buffer, cx| buffer.remove_trailing_whitespace(cx)) + .read_with(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(selection_row_ranges.as_deref(), cx) + }) .await; extend_formatting_transaction(buffer, formatting_transaction_id, cx, |buffer, cx| { buffer.apply_diff(diff, cx); @@ -1649,8 +1676,11 @@ impl LocalLspStore { if settings.ensure_final_newline_on_save { zlog::trace!(logger => "ensuring final newline"); + let diff = buffer.handle.read_with(cx, |buffer, _cx| { + buffer.ensure_final_newline(selection_row_ranges.as_deref()) + }); extend_formatting_transaction(buffer, formatting_transaction_id, cx, |buffer, cx| { - buffer.ensure_final_newline(cx); + buffer.apply_diff(diff, cx); })?; } @@ -1706,9 +1736,13 @@ impl LocalLspStore { let formatters = match (trigger, &settings.format_on_save) { (FormatTrigger::Save, FormatOnSave::Off) => &[], - (FormatTrigger::Manual, _) | (FormatTrigger::Save, FormatOnSave::On) => { - settings.formatter.as_ref() - } + (FormatTrigger::Manual, _) + | ( + FormatTrigger::Save, + FormatOnSave::On + | FormatOnSave::Modifications + | FormatOnSave::ModificationsIfAvailable, + ) => settings.formatter.as_ref(), }; let formatters = code_actions_on_format_formatters @@ -1716,8 +1750,13 @@ impl LocalLspStore { .flatten() .chain(formatters); + // Only explicitly configured formatters surface their failures; + // auto-resolved ones stay silent so a missing optional tool + // (e.g. prettier) does not warn on every save. + let mut format_error = None; for formatter in formatters { - let formatter = if formatter == &Formatter::Auto { + let is_auto = formatter == &Formatter::Auto; + let formatter = if is_auto { if settings.prettier.allowed { zlog::trace!(logger => "Formatter set to auto: defaulting to prettier"); &Formatter::Prettier @@ -1736,16 +1775,23 @@ impl LocalLspStore { &adapters_and_servers, &settings, request_timeout, + trigger, logger, cx, ) .await { zlog::error!(logger => "Formatter failed, skipping: {err:#}"); + if !is_auto { + format_error.get_or_insert(err); + } } } - Ok(()) + match format_error { + Some(err) => Err(err), + None => Ok(()), + } } async fn apply_formatter( @@ -1756,6 +1802,7 @@ impl LocalLspStore { adapters_and_servers: &[(Arc, Arc)], settings: &LanguageSettings, request_timeout: Duration, + trigger: FormatTrigger, logger: zlog::Logger, cx: &mut AsyncApp, ) -> anyhow::Result<()> { @@ -1898,23 +1945,22 @@ impl LocalLspStore { }; let Some(language_server) = language_server else { - log::debug!( - "No language server found to format buffer '{:?}'. Skipping", - buffer_path_abs.as_path().to_string_lossy() + zlog::debug!( + logger => + "No language server found to format buffer {buffer_path_abs:?}. Skipping", ); return Ok(()); }; zlog::trace!( logger => - "Formatting buffer '{:?}' using language server '{:?}'", - buffer_path_abs.as_path().to_string_lossy(), + "Formatting buffer {buffer_path_abs:?} using language server {:?}", language_server.name() ); let edits = if let Some(ranges) = buffer.ranges.as_ref() { zlog::trace!(logger => "formatting ranges"); - Self::format_ranges_via_lsp( + let range_edits = Self::format_ranges_via_lsp( &lsp_store, &buffer.handle, ranges, @@ -1924,7 +1970,38 @@ impl LocalLspStore { cx, ) .await - .context("Failed to format ranges via language server")? + .context("Failed to format ranges via language server")?; + + match range_edits { + Some(edits) => edits, + None => { + if trigger == FormatTrigger::Save + && settings.format_on_save == FormatOnSave::ModificationsIfAvailable + { + zlog::debug!( + logger => + "Falling back to full format - LSP does not support range formatting" + ); + Self::format_via_lsp( + &lsp_store, + &buffer.handle, + buffer_path_abs, + &language_server, + &settings, + cx, + ) + .await + .context("failed to format via language server")? + } else { + zlog::debug!( + logger => + "Skipping range format - language server {:?} does not support range formatting", + language_server.name() + ); + Vec::new() + } + } + } } else { zlog::trace!(logger => "formatting full"); Self::format_via_lsp( @@ -2175,12 +2252,8 @@ impl LocalLspStore { formatting_transaction_id, cx, |buffer, cx| { - zlog::info!( - "Applying edits {edits:?}. Content: {:?}", - buffer.text() - ); + zlog::trace!("Applying {} edits", edits.len()); buffer.edit(edits, None, cx); - zlog::info!("Applied edits. New Content: {:?}", buffer.text()); }, )?; } @@ -2322,14 +2395,18 @@ impl LocalLspStore { language_server: &Arc, settings: &LanguageSettings, cx: &mut AsyncApp, - ) -> Result, Arc)>> { + ) -> Result, Arc)>>> { let capabilities = &language_server.capabilities(); let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref(); - if range_formatting_provider == Some(&OneOf::Left(false)) { - anyhow::bail!( - "{} language server does not support range formatting", + if !matches!( + range_formatting_provider, + Some(OneOf::Left(true) | OneOf::Right(_)) + ) { + log::debug!( + "Skipping range formatting: language server {} does not support range formatting", language_server.name() ); + return Ok(None); } let uri = file_path_to_lsp_url(abs_path)?; @@ -2388,8 +2465,9 @@ impl LocalLspStore { ) })? .await + .map(Some) } else { - Ok(Vec::with_capacity(0)) + Ok(Some(Vec::with_capacity(0))) } } @@ -3411,6 +3489,25 @@ impl LocalLspStore { .to_file_path() .map_err(|()| anyhow!("can't convert URI to path"))?; + // An LSP "rename symbol" can also rename the file, with the text edit + // applied only to the in-memory buffer. Persist it before renaming, or + // fs.rename moves the stale on-disk content and the files' contents swap. + let dirty_buffer = this.update(cx, |this, cx| { + let project_path = this + .worktree_store() + .read(cx) + .project_path_for_absolute_path(&source_abs_path, cx)?; + let buffer = this.buffer_store().read(cx).get_by_path(&project_path)?; + buffer.read(cx).is_dirty().then_some(buffer) + }); + if let Some(buffer) = dirty_buffer { + this.update(cx, |this, cx| { + this.buffer_store() + .update(cx, |buffer_store, cx| buffer_store.save_buffer(buffer, cx)) + }) + .await?; + } + let options = fs::RenameOptions { overwrite: op .options @@ -4172,7 +4269,7 @@ struct CoreSymbol { pub source_language_server_id: LanguageServerId, pub path: SymbolLocation, pub name: String, - pub kind: lsp::SymbolKind, + pub kind: language::SymbolKind, pub range: Range>, pub container_name: Option, } @@ -4196,9 +4293,10 @@ impl SymbolLocation { } fn should_log_lsp_request_failure(message: &str) -> bool { - // content modified is a weird failure mode of rust-analyzer - // where requests are denied before its loaded a project - message.ends_with("content modified") || message.ends_with("server cancelled the request") + // "content modified" and "server cancelled the request" are noisy failure + // modes of rust-analyzer where requests are denied before it has loaded a + // project. + !(message.ends_with("content modified") || message.ends_with("server cancelled the request")) } impl LspStore { @@ -4721,8 +4819,21 @@ impl LspStore { let diagnostic_updates = local .language_servers - .keys() - .cloned() + .iter() + .filter_map(|(server_id, state)| { + let supports_workspace_diagnostics = match state { + LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } => !workspace_diagnostics_refresh_tasks.is_empty(), + _ => false, + }; + if supports_workspace_diagnostics { + None + } else { + Some(*server_id) + } + }) .map(|server_id| DocumentDiagnosticsUpdate { diagnostics: DocumentDiagnostics { document_abs_path: buffer_abs_path.clone(), @@ -6069,31 +6180,9 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>>> { - self.definitions_with_filter(buffer, position, false, cx) - } - - pub fn workspace_definitions( - &mut self, - buffer: &Entity, - position: PointUtf16, - cx: &mut Context, - ) -> Task>>> { - self.definitions_with_filter(buffer, position, true, cx) - } - - fn definitions_with_filter( - &mut self, - buffer: &Entity, - position: PointUtf16, - workspace_only: bool, - cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetDefinitions { - position, - workspace_only, - }; + let request = GetDefinitions { position }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -6118,11 +6207,7 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetDefinitions { - position, - workspace_only, - } - .response_from_proto( + GetDefinitions { position }.response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -6145,10 +6230,7 @@ impl LspStore { let definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetDefinitions { - position, - workspace_only, - }, + GetDefinitions { position }, cx, ); cx.background_spawn(async move { @@ -6164,6 +6246,107 @@ impl LspStore { } } + fn edit_prediction_definitions_for_command( + &mut self, + buffer: &Entity, + request: C, + position: PointUtf16, + cx: &mut Context, + ) -> Task>> + where + C: LspCommand> + Clone, + C::ProtoRequest: proto::LspRequestMessage, + ::Response: + Into<::Response>, + ::Result: Send, + ::Params: Send, + { + if let Some((upstream_client, project_id)) = self.upstream_client() { + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(Vec::new())); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + + let request_task = upstream_client.request_lsp( + project_id, + None, + request_timeout, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(Vec::new()); + }; + let Some(responses) = request_task.await? else { + return Ok(Vec::new()); + }; + let actions = join_all(responses.payload.into_iter().map(|response| { + request.clone().response_from_proto( + response.response.into(), + lsp_store.clone(), + buffer.clone(), + cx.clone(), + ) + })) + .await; + + Ok(actions + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .collect()) + }) + } else { + let definitions_task = + self.request_multiple_lsp_locally(buffer, Some(position), request, cx); + cx.background_spawn(async move { + Ok(definitions_task + .await + .into_iter() + .flat_map(|(_, definitions)| definitions) + .collect()) + }) + } + } + + pub fn edit_prediction_definitions( + &mut self, + buffer: &Entity, + position: PointUtf16, + include_type_definitions: bool, + cx: &mut Context, + ) -> Task>> { + let definitions = self.edit_prediction_definitions_for_command( + buffer, + GetEditPredictionDefinitions { position }, + position, + cx, + ); + let type_definitions = include_type_definitions.then(|| { + self.edit_prediction_definitions_for_command( + buffer, + GetEditPredictionTypeDefinitions { position }, + position, + cx, + ) + }); + cx.background_spawn(async move { + let mut merged = definitions.await?; + if let Some(type_definitions) = type_definitions { + merged.extend(type_definitions.await?); + } + let mut seen = HashSet::default(); + merged.retain(|definition| seen.insert(definition.clone())); + Ok(merged) + }) + } + pub fn declarations( &mut self, buffer: &Entity, @@ -6238,31 +6421,9 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>>> { - self.type_definitions_with_filter(buffer, position, false, cx) - } - - pub fn workspace_type_definitions( - &mut self, - buffer: &Entity, - position: PointUtf16, - cx: &mut Context, - ) -> Task>>> { - self.type_definitions_with_filter(buffer, position, true, cx) - } - - fn type_definitions_with_filter( - &mut self, - buffer: &Entity, - position: PointUtf16, - workspace_only: bool, - cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetTypeDefinitions { - position, - workspace_only, - }; + let request = GetTypeDefinitions { position }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -6285,11 +6446,7 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetTypeDefinitions { - position, - workspace_only, - } - .response_from_proto( + GetTypeDefinitions { position }.response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -6312,10 +6469,7 @@ impl LspStore { let type_definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetTypeDefinitions { - position, - workspace_only, - }, + GetTypeDefinitions { position }, cx, ); cx.background_spawn(async move { @@ -7209,27 +7363,19 @@ impl LspStore { buffer.start_transaction(); for (range, text) in edits { - let primary = &completion.replace_range; - - // Special case: if both ranges start at the very beginning of the file (line 0, column 0), - // and the primary completion is just an insertion (empty range), then this is likely - // an auto-import scenario and should not be considered overlapping - // https://github.com/zed-industries/zed/issues/26136 - let is_file_start_auto_import = { - let snapshot = buffer.snapshot(); - let primary_start_point = primary.start.to_point(&snapshot); - let range_start_point = range.start.to_point(&snapshot); - - let result = primary_start_point.row == 0 - && primary_start_point.column == 0 - && range_start_point.row == 0 - && range_start_point.column == 0; - - result - }; - - let has_overlap = if is_file_start_auto_import { - false + // Zero-width additional edits (e.g. auto-imports at file start, or + // rust-analyzer's ref-match `&` insertions) only overlap the primary + // edit when they fall strictly inside it. Touching its boundary is fine. + // + // Ref: https://github.com/zed-industries/zed/issues/26136 + // Ref: https://github.com/zed-industries/zed/issues/56973 + let is_insertion = range.start.cmp(&range.end, buffer).is_eq(); + let has_overlap = if is_insertion { + let insert_offset = range.start.to_offset(buffer); + all_commit_ranges.iter().any(|commit_range| { + commit_range.start.to_offset(buffer) < insert_offset + && insert_offset < commit_range.end.to_offset(buffer) + }) } else { all_commit_ranges.iter().any(|commit_range| { let start_within = @@ -7242,8 +7388,8 @@ impl LspStore { }) }; - //Skip additional edits which overlap with the primary completion edit - //https://github.com/zed-industries/zed/pull/1871 + // Skip additional edits which overlap with the primary completion edit + // https://github.com/zed-industries/zed/pull/1871 if !has_overlap { buffer.edit([(range, text)], None, cx); } @@ -8023,7 +8169,7 @@ impl LspStore { server_id: LanguageServerId, lsp_adapter: Arc, worktree: WeakEntity, - lsp_symbols: Vec<(String, SymbolKind, lsp::Location, Option)>, + lsp_symbols: Vec<(String, language::SymbolKind, lsp::Location, Option)>, } let mut requests = Vec::new(); @@ -8093,7 +8239,7 @@ impl LspStore { .map(|lsp_symbol| { ( lsp_symbol.name, - lsp_symbol.kind, + lsp_to_symbol_kind(lsp_symbol.kind), lsp_symbol.location, lsp_symbol.container_name, ) @@ -8117,7 +8263,7 @@ impl LspStore { }; Some(( lsp_symbol.name, - lsp_symbol.kind, + lsp_to_symbol_kind(lsp_symbol.kind), location, lsp_symbol.container_name, )) @@ -8618,7 +8764,7 @@ impl LspStore { project_id: *project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: path.as_ref().to_proto(), + path: path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: 0, warning_count: 0, @@ -8909,7 +9055,7 @@ impl LspStore { diagnostics_summary .more_summaries .push(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: new_summary.error_count, warning_count: new_summary.warning_count, @@ -8920,7 +9066,7 @@ impl LspStore { project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: new_summary.error_count, warning_count: new_summary.warning_count, @@ -9004,7 +9150,7 @@ impl LspStore { Ok(ControlFlow::Continue(Some(( *project_id, proto::DiagnosticSummary { - path: path_in_worktree.to_proto(), + path: path_in_worktree.as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: new_summary.error_count as u32, warning_count: new_summary.warning_count as u32, @@ -9521,6 +9667,22 @@ impl LspStore { ) .await?; } + Request::GetEditPredictionDefinition(get_edit_prediction_definition) => { + let position = get_edit_prediction_definition + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_edit_prediction_definition, + position, + &mut cx, + ) + .await?; + } Request::GetDeclaration(get_declaration) => { let position = get_declaration .position @@ -9553,6 +9715,22 @@ impl LspStore { ) .await?; } + Request::GetEditPredictionTypeDefinition(get_edit_prediction_type_definition) => { + let position = get_edit_prediction_type_definition + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_edit_prediction_type_definition, + position, + &mut cx, + ) + .await?; + } Request::GetImplementation(get_implementation) => { let position = get_implementation .position @@ -9777,7 +9955,7 @@ impl LspStore { let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id); let new_worktree_id = WorktreeId::from_proto(envelope.payload.new_worktree_id); let new_path = - RelPath::from_proto(&envelope.payload.new_path).context("invalid relative path")?; + RelPath::from_unix_str(&envelope.payload.new_path).context("invalid relative path")?; let (worktree_store, old_worktree, new_worktree, old_entry) = this .update(&mut cx, |this, cx| { @@ -9846,7 +10024,9 @@ impl LspStore { { let project_path = ProjectPath { worktree_id, - path: RelPath::from_proto(&message_summary.path).context("invalid path")?, + path: RelPath::from_unix_str(&message_summary.path) + .context("invalid path")? + .into(), }; let path = project_path.path.clone(); let server_id = LanguageServerId(message_summary.language_server_id as usize); @@ -9881,7 +10061,7 @@ impl LspStore { diagnostics_summary .more_summaries .push(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: summary.error_count as u32, warning_count: summary.warning_count as u32, @@ -9892,7 +10072,7 @@ impl LspStore { project_id: *project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: project_path.path.as_ref().to_proto(), + path: project_path.path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: summary.error_count as u32, warning_count: summary.warning_count as u32, @@ -11370,7 +11550,7 @@ impl LspStore { project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { - path: path.as_ref().to_proto(), + path: path.as_ref().as_unix_str().to_owned(), language_server_id: server_id.0 as u64, error_count: 0, warning_count: 0, @@ -12385,7 +12565,7 @@ impl LspStore { source_worktree_id: symbol.source_worktree_id.to_proto(), language_server_id: symbol.source_language_server_id.to_proto(), name: symbol.name.clone(), - kind: unsafe { mem::transmute::(symbol.kind) }, + kind: symbol.kind as i32, start: Some(proto::PointUtf16 { row: symbol.range.start.0.row, column: symbol.range.start.0.column, @@ -12402,7 +12582,7 @@ impl LspStore { match &symbol.path { SymbolLocation::InProject(path) => { result.worktree_id = path.worktree_id.to_proto(); - result.path = path.path.to_proto(); + result.path = path.path.as_unix_str().to_owned(); } SymbolLocation::OutsideProject { abs_path, @@ -12418,13 +12598,14 @@ impl LspStore { fn deserialize_symbol(serialized_symbol: proto::Symbol) -> Result { let source_worktree_id = WorktreeId::from_proto(serialized_symbol.source_worktree_id); let worktree_id = WorktreeId::from_proto(serialized_symbol.worktree_id); - let kind = unsafe { mem::transmute::(serialized_symbol.kind) }; + let kind = language::SymbolKind::from_proto(serialized_symbol.kind); let path = if serialized_symbol.signature.is_empty() { SymbolLocation::InProject(ProjectPath { worktree_id, - path: RelPath::from_proto(&serialized_symbol.path) - .context("invalid symbol path")?, + path: RelPath::from_unix_str(&serialized_symbol.path) + .context("invalid symbol path")? + .into(), }) } else { SymbolLocation::OutsideProject { @@ -13967,9 +14148,10 @@ fn lsp_workspace_diagnostics_refresh( let mut requests = 0; loop { - let Some(mut completion_tx) = refresh_rx.recv().await else { + let Some(completion_tx) = refresh_rx.recv().await else { return; }; + let mut completion_txs = completion_tx.into_iter().collect::>(); 'request: loop { requests += 1; @@ -13985,6 +14167,12 @@ fn lsp_workspace_diagnostics_refresh( .await; attempts += 1; + // Absorb refresh requests that queued up in the meantime, so their + // waiters are resolved by this attempt instead of waiting behind it. + while let Ok(queued_refresh) = refresh_rx.try_recv() { + completion_txs.extend(queued_refresh); + } + let Ok(previous_result_ids) = lsp_store.update(cx, |lsp_store, _| { lsp_store .result_ids_for_workspace_refresh(server.server_id(), ®istration_id_shared) @@ -14011,8 +14199,21 @@ fn lsp_workspace_diagnostics_refresh( }; progress_rx.try_recv().ok(); - let timer = server.request_timer(timeout).fuse(); - let progress = pin!(progress_rx.recv().fuse()); + // Restart the timeout whenever a partial result arrives: streaming + // servers may legitimately take longer than a single timeout period, + // but a server that stops streaming without completing the request + // should still time out instead of hanging forever. + let timer = async { + loop { + let timer = pin!(server.request_timer(timeout).fuse()); + let progress = pin!(progress_rx.recv().fuse()); + match select(timer, progress).await { + Either::Left((message, ..)) => break message, + Either::Right((Some(()), ..)) => {} + Either::Right((None, timer)) => break timer.await, + } + } + }; let response_result = server .request_with_timer::( lsp::WorkspaceDiagnosticParams { @@ -14023,10 +14224,7 @@ fn lsp_workspace_diagnostics_refresh( partial_result_token: Some(lsp::ProgressToken::String(token)), }, }, - select(timer, progress).then(|either| match either { - Either::Left((message, ..)) => ready(message).left_future(), - Either::Right(..) => pending::().right_future(), - }), + timer, ) .await; @@ -14035,6 +14233,16 @@ fn lsp_workspace_diagnostics_refresh( match response_result { ConnectionResult::Timeout => { log::error!("Timeout during workspace diagnostics pull"); + // Release everyone waiting on this refresh (e.g. the agent's + // diagnostics tool), including waiters that queued up while the + // request was in flight, so they can fall back to cached + // diagnostics instead of waiting through every retry. + while let Ok(queued_refresh) = refresh_rx.try_recv() { + completion_txs.extend(queued_refresh); + } + for tx in completion_txs.drain(..) { + tx.send(false).ok(); + } continue 'request; } ConnectionResult::ConnectionReset => { @@ -14043,7 +14251,7 @@ fn lsp_workspace_diagnostics_refresh( } ConnectionResult::Result(Err(e)) => { log::error!("Error during workspace diagnostics pull: {e:#}"); - if let Some(tx) = completion_tx.take() { + for tx in completion_txs.drain(..) { tx.send(false).ok(); } break 'request; @@ -14063,7 +14271,7 @@ fn lsp_workspace_diagnostics_refresh( { return; } - if let Some(tx) = completion_tx.take() { + for tx in completion_txs.drain(..) { tx.send(true).ok(); } break 'request; @@ -14388,7 +14596,12 @@ impl LanguageServerWatchedPaths { cx.spawn({ async move |_, cx| { maybe!(async move { - let mut push_updates = fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await; + let mut push_updates = cx + .background_spawn({ + let abs_path = abs_path.clone(); + async move { fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await } + }) + .await; while let Some(update) = push_updates.0.next().await { let action = lsp_store .update(cx, |this, _| { @@ -14666,7 +14879,7 @@ impl DiagnosticSummary { path: &RelPath, ) -> proto::DiagnosticSummary { proto::DiagnosticSummary { - path: path.to_proto(), + path: path.as_unix_str().to_owned(), language_server_id: language_server_id.0 as u64, error_count: self.error_count as u32, warning_count: self.warning_count as u32, @@ -15286,3 +15499,28 @@ fn extend_formatting_transaction( Ok(()) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_log_lsp_request_failure_suppresses_known_noise() { + // Suppressed: rust-analyzer's superseded/denied request signals. + assert!(!should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: content modified" + )); + assert!(!should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: server cancelled the request" + )); + + // Logged: anything else is a real failure. + assert!(should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: server shut down" + )); + assert!(should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: Server reset the connection" + )); + assert!(should_log_lsp_request_failure("something else entirely")); + } +} diff --git a/crates/project/src/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs index 98f66d9fdb365f..babb08ffc1de79 100644 --- a/crates/project/src/lsp_store/document_symbols.rs +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -328,6 +328,7 @@ fn enriched_symbol_text( mod tests { use super::*; use gpui::TestAppContext; + use language::lsp_to_symbol_kind; use text::{OffsetRangeExt, Point, Unclipped}; fn make_symbol( @@ -340,7 +341,7 @@ mod tests { use text::PointUtf16; DocumentSymbol { name: name.to_string(), - kind, + kind: lsp_to_symbol_kind(kind), range: Unclipped(PointUtf16::new(range.start.0, range.start.1)) ..Unclipped(PointUtf16::new(range.end.0, range.end.1)), selection_range: Unclipped(PointUtf16::new( diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index dd7010275dc59f..bb994492d00f94 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -443,7 +443,6 @@ impl LspCommand for GoToParentModule { lsp_store, buffer, server_id, - false, cx, ) .await diff --git a/crates/project/src/manifest_tree/path_trie.rs b/crates/project/src/manifest_tree/path_trie.rs index 99b4d523e0eff2..ba0a3619e7d967 100644 --- a/crates/project/src/manifest_tree/path_trie.rs +++ b/crates/project/src/manifest_tree/path_trie.rs @@ -4,6 +4,7 @@ use std::{ sync::Arc, }; +use path::rel_path::RelPathBuf; use util::rel_path::RelPath; /// [RootPathTrie] is a workhorse of [super::ManifestTree]. It is responsible for determining the closest known entry for a given path. @@ -59,12 +60,12 @@ impl RootPathTrie